Skip to main content

Tasks and coroutines

Work that goes on over time, such as a broadcast every few minutes, a countdown or a combat tag, is a Kotlin coroutine of the plugin. A class implementing BackgroundTask runs by itself as long as the plugin does, and is restarted with the new values by every reload; nothing is scheduled, remembered or cancelled by hand:

class AutoBroadcastTask(private val logger: Logger) : BackgroundTask {
@Value("auto-broadcast.period")
var period = 30.seconds

@Value("auto-broadcast.lines")
var lines = emptyList<String>()

override suspend fun run() {
if (lines.isEmpty()) {
logger.info("No lines to broadcast; add some to config.yml and reload")
return
}
logger.info("Broadcasting ${lines.size} lines every $period")
val rotation = lines.cycle()
every(period) { VersatiaEngine.broadcast(text(rotation.next())) }
}
}

No bean scope is needed: the runtime creates the task with its constructor injected, here with the plugin's Logger, and sets its @Value properties.

Everything here lives in dev.versatia.task, and the coroutines run on the server thread, so Bukkit calls are safe in them. VersatiaAPI brings kotlinx.coroutines; see Installation for why your plugin must not add it.

You wantUse
work that runs as long as the plugin: a broadcast, particles, an autosavea BackgroundTask
work something starts: an event, a commandscope.launch { } on an injected PluginScope
one job per player or arena, replaced by the next onescope.keyedJobs<K>()
to wait for a state, or to run only while it holdsa Condition with awaitUntil, runOnceWhile or whenever
to wait or repeatdelayTicks, delay, everyTicks, every
blocking I/OwithContext(Dispatchers.IO)
a plain Bukkit taskthe scheduler helpers

Background tasks​

Every class of the plugin implementing BackgroundTask runs; there is nothing to register or to annotate. Its run() is a suspend function called in the plugin's PluginScope:

interface BackgroundTask {
suspend fun run()
}
The task is…run() is called on
a bean (@LocalBean, @ExposeSingleton, …)the bean itself
a plain classan instance created once per plugin like a bean: one constructor, parameters injected, @Qualifier honoured, @Value properties set; it is not put into the container
an objectthe object
the plugin's main classthe plugin instance

A plain class that is also a Listener is created once: the same instance is registered and runs. A plain task may hold @Value properties, in its body or as constructor vars, without becoming a bean: they are set right after its constructor, before its first run, and again at every reload before it restarts, as on a bean. Its other members take no enhancements: an @AutoInvoke function needs a bean, an object or the main class.

EventThe task
STARTrun() begins at the tick after the plugin started, so after its start functions
RELOADthe running call is cancelled once the new values are set and the Reloadable beans reloaded, before the reload functions run; a new call begins at the next tick, once the cancelled one has finished
STOPcancelled with every other coroutine of the plugin, before the stop functions run

run() may return: the task then rests until the next reload, as the empty list above does. One that throws is logged with its class name and rests the same way; see Failures. Each call is a coroutine named after the class (CoroutineName), a child of the plugin's scope, so the coroutines it launches are cancelled with it.

A task is therefore written for the values it sees when it begins and never watches for a reload: reading period at the top of run() is enough, the next reload calls run() again. The conventions: the class name ends in Task and the class lives in the package of the feature it serves; see Best practices.

Your own coroutines​

A bean, a listener or an auto-invoked function takes a PluginScope and launches from it. The scope is a CoroutineScope, so launch, async and the rest of kotlinx.coroutines work as usual:

@LocalBean
class CombatTags(scope: PluginScope) : Listener {
@Value("combat-tag.duration")
var duration = 10.seconds

private val tags = scope.keyedJobs<UUID>()

@EventHandler(ignoreCancelled = true)
fun onDamage(event: EntityDamageByEntityEvent) {
val player = event.entity as? Player ?: return
if (event.damager !is LivingEntity) return
tags.launch(player.uniqueId) {
delay(duration)
player.sendMessage(text("You are out of combat"))
}
}

@EventHandler
fun onQuit(event: PlayerQuitEvent) {
if (tags.cancel(event.player.uniqueId)) event.player.health = 0.0
}
}
  • Launched on the server thread, a coroutine starts at once: from an event handler, a command or a start function it runs up to its first suspension before launch returns; launched from another thread, it starts at the next tick. See Threads.
  • A reload leaves the scope's coroutines alone: a tag running with the old duration goes on; the next hit uses the new one. The plugin's stop cancels them before its stop functions run; see Reload and stop.
  • A failing coroutine is logged through the plugin's logger and the others go on; give a coroutine a CoroutineName and the log line names it.
  • One scope per plugin. VersatiaCore exposes PluginScope as a prototype: every plugin injecting it gets its own, and so does every module prototype made for that plugin, which then runs its coroutines as the consumer's.
  • Cancel the jobs you launch, never the scope. A cancelled scope runs nothing until the plugin restarts, and says so: PluginScope was cancelled by plugin code, so nothing launched from it runs until the plugin restarts; cancel the jobs you launch, not the scope. The pair below the list shows the difference.
  • Repeating work is a BackgroundTask, not a launch from a PLUGIN_RELOAD_OR_START function: the scope survives the reload, so such a function would start a second copy at every reload. Launch from events, commands and PLUGIN_START_ONLY functions.
Wrong: stops every coroutine of the plugin
@LocalBean
class Countdown(private val scope: PluginScope) {
fun start() = scope.launch { countDown() }

fun stop() = scope.cancel()
}
Right: cancels the job it launched
@LocalBean
class Countdown(private val scope: PluginScope) {
private var job: Job? = null

fun start() {
job = scope.launch { countDown() }
}

fun stop() = job?.cancel()
}

Keyed jobs​

scope.keyedJobs<K>() keeps at most one job per key, for state that belongs to a player, an arena or a block. It is thread-safe.

MemberDoes
launch(key) { }cancels the job of key, if any, then launches the block as its new job; returns the Job
cancel(key)cancels the job of key; true when one was running
key in jobswhether the job of key is still active
keysthe keys whose job has not completed yet
cancelAll()cancels every job

An entry goes by itself when its job completes, so the map never holds finished jobs. The jobs are children of the scope, so the plugin's stop cancels them too.

Per-player state​

State that lives only while the server runs, a combat tag, an open menu, a pending request, is a scope.perPlayer<T>(): one value per player, thread-safe, that goes by itself when the player quits.

@LocalBean
class Combat(scope: PluginScope) {
private val tags = scope.perPlayer<Instant>(keepAfterQuit = 30.seconds)

fun tag(player: Player) {
tags[player] = Instant.now().plusSeconds(15)
}

fun isTagged(player: Player) = tags[player]?.isAfter(Instant.now()) == true
}
MemberDoes
tags[player]the player's value, or null
tags[player] = valuesets it
tags.remove(player)removes it and returns it
player in tagswhether there is one
tags.getOrPut(player) { }the value, made by the block when there is none

A player's value goes when they quit, after the plugin's own quit handlers ran, so they can still read it. With keepAfterQuit it goes that long after the quit instead, unless the player joins again meanwhile: a combat tag that should punish a log-out survives it. A reload keeps the values; the plugin's stop clears them and removes the listeners. Nothing is written anywhere: data that must survive a restart belongs in a database or a file.

Ticks and time​

CallWaits
delayTicks(n), delayTick()n server ticks, game time: a lagging server stretches it. 0 waits for the next tick, like runTask; a negative value is an error
delay(duration) (kotlinx)wall-clock time; the coroutine resumes at the first tick after the deadline
yield()on the server thread, the next tick
everyTicks(periodTicks, delayTicks = periodTicks) { }runs the block at a fixed rate in ticks, the first time after delayTicks
every(period, delay = period) { }the same in wall-clock time
everyTicks(20) { spawnParticles() } // every second of game time, from one second on
everyTicks(10, delayTicks = 0) { spawnParticles() } // from now
every(5.minutes) { autosave() } // every five minutes of real time

The repeating helpers follow the same rules:

  • Fixed rate. A slot missed while the block suspended, or during a lag spike, is skipped, never caught up; a slot due the moment the block returns runs at once.
  • A failing run does not end the loop. It is logged and the next run follows on schedule; the same failure, the same exception class and message, is logged once until a run succeeds.
  • They never return (their type is Nothing); the loop ends when the coroutine is cancelled, so code after them never runs. Put cleanup in a finally.
  • Arguments are checked: The period must be at least one tick, got 0, The delay must not be negative, got -1 ticks, The period must be positive, got 0s. The tick helpers take an Int too, for a value read from configuration.

A period in configuration is best a Duration: @Value("period") var period = 30.seconds reads period: 30s, 5m, 1h 30m, 500ms or PT5M. These helpers need the clock of a Versatia coroutine; called elsewhere, in a runBlocking or GlobalScope coroutine, they fail with No server clock in this coroutine: launch it from an injected PluginScope or run it in a BackgroundTask; runBlocking and GlobalScope coroutines have none.

Conditions​

A Condition is the last result of a check, evaluated again only when one of its events fires or recheck() is called, never by polling, so the check may be expensive. It is a StateFlow<Boolean>: value is the current result, and three helpers consume it.

HelperDoes
awaitUntil(condition)suspends until the condition is true; returns at once when it already is
runOnceWhile(condition) { }runs the block once, cancelling it as soon as the condition turns false; returns whether it completed. A false condition never starts it
whenever(condition) { }starts the block when the condition turns true, cancels it when it turns false, starts it again when it turns true once more; never returns for a Condition
@LocalBean
class Teleports(scope: PluginScope) : Listener {
private val pending = scope.keyedJobs<UUID>()

@EventHandler
fun onInteract(event: PlayerInteractEvent) {
if (!event.action.isRightClick || event.item?.type != Material.COMPASS) return
val player = event.player
val start = player.location
pending.launch(player.uniqueId) {
val stays = condition(recheckOn<PlayerMoveEvent>(player), recheckOn<PlayerQuitEvent>(player)) {
player.isOnline && player.location.isWithin(1.5, start)
}
val stayed = runOnceWhile(stays) {
for (left in 5 downTo 1) {
player.sendActionBar(text("Teleporting in $left s, stand still"))
delayTicks(20)
}
}
when {
stayed -> player.teleport(player.world.spawnLocation)
player.isOnline -> player.sendMessage(text("Teleport cancelled: you moved"))
}
}
}
}

A condition is made in one of two places:

Made withLives
scope.condition(triggers…) { check }as long as the plugin: its listeners go when the plugin stops
condition(triggers…) { check } in a coroutineas long as that coroutine: its listeners go when it completes, so one per request costs nothing once the request ends

Both evaluate the check at once, on the server thread, which is also where a condition is made: made on another thread it fails with A condition is created on the server thread, where its check runs; create it in a bean, a start function or a coroutine of the plugin. A condition is named after the state it describes (stays, anyoneOnline), without is and without a Condition suffix.

whenever suits a background task that should run only in some state:

class SpawnBeaconTask(private val logger: Logger) : BackgroundTask {
override suspend fun run() {
val anyoneOnline = condition(recheckOn<PlayerJoinEvent>(immediately = true), recheckOn<PlayerQuitEvent>()) {
VersatiaEngine.onlinePlayers.isNotEmpty()
}
whenever(anyoneOnline) {
logger.info("Beacon on")
try {
everyTicks(10, delayTicks = 0) { spawnSparks() }
} finally {
logger.info("Beacon off")
}
}
}
}

The condition belongs to the call of run(), so a reload, which cancels the call, takes its listeners along and the next call makes a fresh one.

Rechecks​

A trigger is recheckOn<E>(): every event of type E that passes the filter makes the condition check again.

recheckOn<PlayerMoveEvent>() // every move
recheckOn<PlayerMoveEvent> { it.player.uniqueId == id } // a filter
recheckOn<PlayerJoinEvent>(immediately = true) // checked in the handler
recheckOn<BlockBreakEvent>(priority = EventPriority.HIGH, ignoreCancelled = false)
ParameterDefaultMeans
priorityMONITORthe priority of the listener behind the trigger
ignoreCancelledtruea cancelled event does not trigger
immediatelyfalsecheck inside the handler instead of at the next tick
filterevery eventruns on the event's thread, so it must be cheap and thread-safe

By default the check runs once, at the next tick, on the server thread, however many events came: a thousand moves in one tick cost one check. condition.recheck() checks by hand, right after you change what the check reads: called on the server thread it updates value before it returns, called from another thread it checks at the next tick.

Closing a condition​

A condition made for one request belongs to that request's coroutine. Made with scope.condition, it lives as long as the plugin instead:

Wrong: a plugin-long condition per /home
@LocalBean
class Homes(private val scope: PluginScope) {
fun teleportHome(player: Player, home: Location) {
val start = player.location.block
val standsStill = scope.condition(recheckOn<PlayerMoveEvent>(player)) {
player.location.block == start
}
scope.launch {
if (runOnceWhile(standsStill) { delay(5.seconds) }) player.teleport(home)
}
}
}
Right: the condition belongs to the coroutine
@LocalBean
class Homes(private val scope: PluginScope) {
fun teleportHome(player: Player, home: Location) {
val start = player.location.block
scope.launch {
val standsStill = condition(recheckOn<PlayerMoveEvent>(player)) {
player.location.block == start
}
if (runOnceWhile(standsStill) { delay(5.seconds) }) player.teleport(home)
}
}
}

The first keeps its PlayerMoveEvent listener after the teleport, one more for every /home until the plugin stops; the second loses it when the coroutine ends.

condition.close() ends a condition before its owner does: its events stop triggering it, rechecks do nothing and value keeps the last result. A condition that must live outside a coroutine, such as one per online player, is made with scope.condition and closed when its player leaves:

Right: closed when the player leaves
@LocalBean
class Grounded(private val scope: PluginScope) : Listener {
private val conditions = mutableMapOf<UUID, Condition>()

fun of(player: Player) = conditions.getValue(player.uniqueId)

@EventHandler
fun onJoin(event: PlayerJoinEvent) {
val player = event.player
conditions[player.uniqueId] = scope.condition(recheckOn<PlayerMoveEvent>(player)) { player.isOnGround }
}

@EventHandler
fun onQuit(event: PlayerQuitEvent) {
conditions.remove(event.player.uniqueId)?.close()
}
}

A Condition is AutoCloseable, so use { } closes one at the end of a block:

scope.condition(recheckOn<PlayerMoveEvent>(player)) { player.isOnGround }.use { grounded ->
awaitUntil(grounded)
}

In the handler or at the next tick​

Most Bukkit events fire before their change is applied, so a check run inside the handler would still see the old state. That is why the default waits for the next tick; immediately = true is for the events that fire after their change.

EventFiresTrigger
PlayerMoveEventbefore the move: the player is still at the old locationrecheckOn<PlayerMoveEvent>(player)
PlayerQuitEventbefore the player is removed: still onlinerecheckOn<PlayerQuitEvent>()
EntityDamageEventbefore the damage: the health is unchangedrecheckOn<EntityDamageEvent>(entity)
BlockBreakEventbefore the block is removedrecheckOn<BlockBreakEvent>(block)
PlayerJoinEventafter the player is added: onlinerecheckOn<PlayerJoinEvent>(immediately = true)
PlayerChangedWorldEventafter the player arrived in the new worldrecheckOn<PlayerChangedWorldEvent>(player, immediately = true)
AsyncChatEvent and other asynchronous eventson another threadrecheckOn<AsyncChatEvent>(): checked at the next tick on the server thread

A check never runs off the server thread. An immediately trigger whose event fires on another thread is checked at the next tick as well, and the first such event logs a warning: recheckOn<AsyncChatEvent>(immediately = true) got AsyncChatEvent on the thread ..., not the server thread; conditions are always checked on the server thread, so it is checked at the next tick instead. Remove immediately = true for this event.

Subject shorthands​

A trigger limited to one player, entity or block takes it as the first argument; the event type picks the subject, and the other parameters are those of recheckOn<E>():

ShorthandFor events of typeMatches
recheckOn<E>(player)PlayerEventthe player's UUID, so a player who rejoined still counts
recheckOn<E>(entity)EntityEventthe entity's UUID; recheckOn<EntityDamageEvent>(player) is the damage of a player
recheckOn<E>(vehicle)VehicleEventthe vehicle's UUID
recheckOn<E>(hanging)HangingEventthe item frame's or painting's UUID
recheckOn<E>(block)BlockEventthe same world and position
recheckOn<E>(inventory)InventoryEventthe same inventory, such as clicks in one menu and its closing
recheckOn<E>(world)WorldEventthe world's UUID

Weather events are WeatherEvents, not WorldEvents, and a second World shorthand for them would clash with the first; they take the filter form, recheckOn<WeatherChangeEvent> { it.world == world }.

Threads​

The coroutines of a PluginScope, background tasks included, run on the server thread unless they switch with withContext. Blocking work moves to a worker thread and comes back for the Bukkit calls:

@LocalBean
class Greetings(private val plugin: ThreadsPlugin, private val scope: PluginScope) : Listener {
@EventHandler
fun onJoin(event: PlayerJoinEvent) {
val player = event.player
scope.launch {
val motd = withContext(Dispatchers.IO) { plugin.dataFolder.resolve("motd.txt").readText().trim() }
player.sendMessage(text(motd)) // back on the server thread
}
}
}
FromTo the server thread
a coroutine of the scope, after withContext(Dispatchers.IO) { }by itself, at the next tick
plain code on another thread (an async event, a callback)scope.launch { }: starts at the next tick
a coroutine of another scopewithContext(scope.serverThread) { }
a Java-style futurefuture.thenAcceptAsync({ use(it) }, scope.serverThread.asExecutor())

scope.serverThread is the scope's dispatcher: in place when already on the server thread, otherwise at the next tick. A server has no Dispatchers.Main; using it fails with Module with the Main dispatcher is missing, so use the scope instead. Never block the server thread: Thread.sleep, runBlocking or future.get() in a coroutine of the scope stop the whole server, not only the coroutine.

Reload and stop​

WhatAt a reloadAt the stop
a BackgroundTask's callcancelled after the values are set and the Reloadable beans reloaded, before the reload functions; called again at the next tickcancelled before the stop functions
coroutines launched from a PluginScopeleft alonecancelled before the stop functions
a condition made with scope.conditionkeptits listeners go
the values of scope.perPlayerkeptcleared, its listeners go
a condition made in a coroutinegoes with its coroutinegoes with its coroutine

A reload cancels a task's call, so its finally blocks run inside the reload, before the reload functions; the new call waits until the cancelled one has finished. The stop goes in three steps:

  1. Every coroutine of the plugin is cancelled. The runtime runs them on the server thread until all have finished or five seconds have passed, so their finally blocks run before anything else of the stop; ticks no longer pass for the plugin, and a delayTicks in a finally fails at once with Plugin X is being disabled; no tick passes for it any more.
  2. The PLUGIN_STOP functions run.
  3. The beans are closed.

Cleanup in a finally is therefore synchronous, or done in withContext(NonCancellable + Dispatchers.IO) { } within those five seconds. What is still running after them is reported (2 coroutines were still running 5s after the plugin was disabled (...)) and finishes when it next resumes, after the plugin is gone.

Failures​

Nothing a coroutine throws reaches Bukkit; the runtime logs it on the plugin's logger:

Log lineLevelWhen
Background task com.example.AutoBroadcastTask failed; it starts again at the next reloadSEVERErun() threw; the stack trace follows
Background task ... ended with TimeoutCancellationException although nobody cancelled it; it starts again at the next reloadWARNINGa CancellationException left run() while nothing cancelled it, typically from withTimeout; use withTimeoutOrNull
Background task ... is still finishing its previous run 5s after the reload cancelled it; the new run starts when it hasWARNINGthe cancelled call's finally takes long
Coroutine combat-tag failed, A coroutine failedSEVEREa coroutine launched from the scope threw; the name is its CoroutineName. after the plugin was disabled is added when it ran after the stop
Coroutine ...: a repeated run failed; it runs again on schedule, and the same failure is not logged again until a run succeedsSEVEREa run of everyTicks or every threw
A condition check failed; the condition keeps its last value, trueSEVEREa check run for a trigger threw
recheckOn<E>(immediately = true) got ... on the thread ..., not the server thread; ...WARNINGonce per event type; see In the handler or at the next tick
PluginScope was cancelled by plugin code, so nothing launched from it runs until the plugin restarts; cancel the jobs you launch, not the scopeWARNINGscope.cancel()
1 coroutines were still running 5s after the plugin was disabled (stubborn); they are cancelled and finish when they next resumeWARNINGthe stop's wait ran out
Plugin X is being disabled from inside a running coroutine, so its own coroutines finish only after that coroutine suspends, once the plugin is gone; disable plugins from a command or a listener, not from a coroutineWARNINGa coroutine disabled its own plugin
A coroutine threw while running on the server threadSEVEREa block failed outside any coroutine's own handling; the tick goes on

The task line names the class; a coroutine of your own is named only with a CoroutineName, so give the long-lived ones a name: scope.launch(CoroutineName("combat-tag")) { }. More causes and fixes are in Troubleshooting.

Switching it off​

@DisableVersatiaFeatures(runBackgroundTasks = true)
class MyPlugin : VersatiaPlugin()

The plugin's BackgroundTasks are then never called; a task that is a bean is still created and its values are still set. An injected PluginScope still works, but it is cancelled only when the plugin's beans are closed, after the stop functions, so a stop function may meet coroutines that are still running. Every feature a plugin inherits and its switch are listed under Enhancements on a base class.

Migrating from Bukkit tasks​

BukkitVersatia
runTaskTimer in onEnable, cancelled and rescheduled by a reload commanda BackgroundTask with everyTicks or every; the reload restarts it
a BukkitRunnable subclass with runTaskTimer(plugin, delay, period)class XTask : BackgroundTask whose run() calls everyTicks(periodTicks, delayTicks) { }
runTaskAsynchronously and a runTask back to the main threadwithContext(Dispatchers.IO) { } around the blocking part only; the code after it is back on the server thread
a repeating task that checks a state on every ticka Condition with recheckOn triggers, consumed by whenever(condition) { }
code at the start and end of that state, kept in flagsthe start of the whenever block and its finally
a countdown: a counter field decremented by a repeating taska for loop with delayTicks in runOnceWhile, launched in KeyedJobs; the code after the loop is the completion
a Map<UUID, BukkitTask> of per-player tasksscope.keyedJobs<UUID>(): launching for a key replaces its job
task.cancel(), isCancelled(), Bukkit.getScheduler().isCurrentlyRunning(id)job.cancel() and job.isActive, or jobs.cancel(key) and key in jobs

A timer kept from Bukkit habits survives the reload, so a reload function scheduling it adds one more each time:

Wrong: one more autosave timer at every reload
object Autosave {
@AutoInvoke(Phase.PLUGIN_RELOAD_OR_START)
fun start(plugin: JavaPlugin) = plugin.runTaskTimer(periodTicks = 6000) { save() }
}
Right: a background task, restarted by the reload
object AutosaveTask : BackgroundTask {
override suspend fun run() {
every(5.minutes) { save() }
}
}

The scheduler helpers stay for plain Bukkit tasks: a one-off plugin.runTaskLater, or an API that wants a BukkitTask. Bukkit cancels those when the plugin is disabled, and a reload leaves them alone.

Corner cases​

  • A launch from inside another coroutine may wait for it. On the server thread a launch runs in place, but one made from inside a coroutine running there in place, started from a handler or resumed by a tick wait, is queued until that coroutine suspends: scope.launch { } then starts after the code that follows it. Start it with CoroutineStart.UNDISPATCHED when it must run at once.
  • What must affect an event happens before the first suspension. A coroutine launched from a handler runs in place up to its first suspension, while the event is still being handled; event.isCancelled = true or a changed event field after a delay comes too late.
  • Blocking the server thread blocks the server. Thread.sleep, runBlocking and future.get() in a coroutine of the scope freeze every plugin and the game; runBlocking waiting for the server thread deadlocks it.
  • A withContext in a finally of a cancelled coroutine throws when it returns. withContext(NonCancellable + Dispatchers.IO) { save() } runs its block, but the cancelled caller then gets a CancellationException instead of the result, so code after it in the same finally does not run; wrap what must follow in a try/finally of its own.
  • Disabling a plugin from one of its coroutines skips the wait. Its coroutines finish only after that coroutine suspends, once the plugin is gone, and a warning says so; disable plugins from a command or a listener.
  • A condition is a state, not a stream of events. Changes that cancel each other out before the check runs are not seen: a player who leaves the area and comes back within one tick never made stays false.
  • The check at the next tick sees the change; the one in the handler does not. A moving player is still at the old location during PlayerMoveEvent, a quitting one still online during PlayerQuitEvent; recheckOn<PlayerQuitEvent>(immediately = true) would still count them online.
  • An immediately trigger on an asynchronous event is checked at the next tick, with one warning per event type.
  • Weather events take the filter form, recheckOn<WeatherChangeEvent> { it.world == world }.
  • Rechecks come before timers. Within one tick the rechecks run before the tick waits end, so a condition turning false in the tick a countdown ends cuts the countdown short.
  • A failing check reaches the caller at creation and in condition.recheck() called on the server thread, while a recheck run by a trigger logs the exception and keeps the condition's last value.
  • scope.condition(...) lives as long as the plugin, listeners included, so one made per player or per request piles up until the plugin stops. Close it with condition.close() when its player leaves, or make it with condition(...) inside the request's coroutine, which closes it when it ends.
  • A closed condition keeps its last value; recheck() and its triggers do nothing, a recheck already queued for the next tick is dropped, and closing it again does nothing. close() may be called from any thread.
  • @Value in a plain task is set before its first run and at every reload before it restarts, on the one instance the plugin creates; a broken file leaves the running values as they were.
  • A plain class that is a Listener and a BackgroundTask is one instance, registered and run.
  • delayTicks(0) waits for the next tick, never resumes in place, so a loop around it cannot freeze the server; a negative delay is an error.
  • Ticks are the server's, not the game's. delayTicks counts the ticks that drive Bukkit's scheduler: /tick rate changes how long one lasts, and /tick freeze, which stops the game but not the server loop, does not stop them. delay and every count wall-clock time either way.
  • A task that returned or failed rests until the next reload; nothing restarts it before.
  • A task's coroutines go with its call. A reload cancels what run() launched as its children, while coroutines launched from the injected scope belong to the plugin and survive.
  • Ticks stop for a stopping plugin. During the stop a delayTicks fails at once and a coroutine launched afterwards never runs.
  • A plugin's own PluginScope bean shadows VersatiaCore's, and the start fails: the PluginScope bean is C, not VersatiaCore's; background tasks and the stop order need VersatiaCore's.

How it works​

VersatiaPlugin carries @RunBackgroundTasks, a subclass enhancement for BackgroundTask that also binds the plugin's PluginScope to its lifecycle. One Bukkit timer owned by VersatiaCore drives every plugin's coroutines once per tick; the order within a tick, the dispatcher, the conditions and the stop are described in The runtime.