java.lang.Object
com.ultikits.ultitools.abstracts.gui.declarative.engine.GuiScheduler

public class GuiScheduler extends Object
GuiScheduler is responsible for ensuring every GUI operation runs on the Bukkit main thread.

It provides:

  • a check for whether the current thread is the main thread
  • scheduling a task to run on the main thread
  • frame scheduling: coalescing multiple update requests within a short window
  • prevention of duplicate scheduling

Thread-safety contract: State.setState(...) and Element.markNeedsBuild() are safe to call from any thread -- a call made off the main thread is marshalled onto it, and multiple calls that land within the same frame window are coalesced via this scheduler's AtomicBoolean compare-and-set guard, at a nominal 16ms frame interval (floored to one Bukkit tick when scheduling has to wait for one). flush(), by contrast, throws IllegalStateException when called off the main thread -- it runs pending work synchronously rather than marshalling it.

Per-frame task budget: one frame runs only the tasks that were already queued when it started. A task queued while the frame is running -- for example the follow-up rebuild a failing build replays from its finally -- runs on a later frame, which scheduleFrame(java.lang.Runnable) schedules on the Bukkit scheduler (never inline inside the running frame). This bounds the main-thread time one frame can take: a widget whose State.initState requests a rebuild and then throws is retried once per frame while its page is open, instead of keeping the server thread inside a single frame forever. No queued task is dropped; it only waits for the next frame.

Frame scheduling:

 Timeline:
 |----16ms----|----16ms----|----16ms----|
    ↑ ↑           ↑
   setState     rebuild actually runs
    ↑
   setState (coalesced into the same frame)
 
Since:
6.2.0
Version:
1.0.0
Author:
UltiTools Team
  • Field Details

    • DEFAULT_FRAME_INTERVAL_MS

      private static final long DEFAULT_FRAME_INTERVAL_MS
      The default frame interval, in milliseconds. Minecraft's default tick is 50ms, but GUI updates can run more frequently.
      See Also:
    • plugin

      private final org.bukkit.plugin.Plugin plugin
    • frameIntervalMs

      private final long frameIntervalMs
    • isScheduled

      private final AtomicBoolean isScheduled
    • pendingTasks

      private final ConcurrentLinkedQueue<Runnable> pendingTasks
    • lastFrameTime

      private long lastFrameTime
    • frameRunning

      private volatile boolean frameRunning
      True while executeFrame() is draining on the main thread. A frame requested during that drain is always handed to the Bukkit scheduler rather than run inline, so a slow frame (one that outlasts the frame interval) cannot start a nested frame from inside itself.
  • Constructor Details

    • GuiScheduler

      public GuiScheduler()
      Creates a GuiScheduler using the default plugin instance.
    • GuiScheduler

      public GuiScheduler(@NotNull @NotNull org.bukkit.plugin.Plugin plugin)
      Creates a GuiScheduler.
      Parameters:
      plugin - the plugin instance
    • GuiScheduler

      public GuiScheduler(@NotNull @NotNull org.bukkit.plugin.Plugin plugin, long frameIntervalMs)
      Creates a GuiScheduler with the given frame interval.
      Parameters:
      plugin - the plugin instance
      frameIntervalMs - the frame interval, in milliseconds
  • Method Details

    • isOnMainThread

      public boolean isOnMainThread()
      Checks whether the current thread is the main thread.
      Returns:
      true if on the main thread
    • runOnMainThread

      public void runOnMainThread(@NotNull @NotNull Runnable task)
      Ensures the task runs on the main thread.

      If already on the main thread, runs immediately. Otherwise, schedules it to run on the main thread.

      Gate-2 Codex finding, PR #478 round 1: this is the path GuiRenderer.initialize(java.util.function.Supplier<com.ultikits.ultitools.abstracts.gui.declarative.core.Widget>, com.ultikits.ultitools.abstracts.gui.declarative.core.BuildContext) drives the FIRST build through, and GuiRenderer.performBuild()'s own off-main-thread re-entrant guard -- neither passes through executeFrame()'s try/catch, which is where RenderDepthExceededException reporting (WR-01) lived until now. Since mounting is where the depth guard (CR-01) actually fires, and mounting happens on the very first build, that build's own trip is the MORE common case to miss, not an edge case. Both branches below now report it; only the already-on-main-thread branch re-throws afterward (preserving the existing "the caller of runOnMainThread() sees the exception synchronously" contract GuiRenderer.initialize(java.util.function.Supplier<com.ultikits.ultitools.abstracts.gui.declarative.core.Widget>, com.ultikits.ultitools.abstracts.gui.declarative.core.BuildContext) and every CR-01 test depend on) -- the off-thread branch's caller could never observe the deferred task's exception anyway (Bukkit's own scheduler runs it later), so there is nothing to preserve there beyond adding the report.

      Parameters:
      task - the task to run
    • scheduleFrame

      public void scheduleFrame(@NotNull @NotNull Runnable frameTask)
      Schedules a frame task.

      This method implements the frame-coalescing mechanism: multiple calls within the same frame window trigger only one actual execution. A call made while a frame is running queues its task for a later frame (see the class javadoc's per-frame task budget).

      Parameters:
      frameTask - the frame task
    • executeFrame

      private void executeFrame()
      Runs the frame tasks that were pending when this frame started.

      The budget is the queue size read before the first task runs. A task queued during the drain stays queued for a later frame: isScheduled is cleared before the drain, so the first scheduleFrame(java.lang.Runnable) call made during it schedules that later frame, and frameRunning makes sure it is scheduled on the Bukkit scheduler rather than run inline from inside this one.

    • drainFrameBudget

      private void drainFrameBudget(int budget)
      Runs at most budget queued tasks, catching and reporting each task's failure.
      Parameters:
      budget - the number of tasks this frame may run
    • logFrameTaskError

      private void logFrameTaskError(Exception e)
      The console-visible half of a GUI frame task's failure -- a warning naming the task plus a full stack trace. Shared by every catch clause in drainFrameBudget(int) so the two log lines cannot drift between the exception types that also do additional reporting (RenderDepthExceededException) and those that do not.
      Parameters:
      e - the failure to log
    • reportRenderDepthExceeded

      private void reportRenderDepthExceeded(RenderDepthExceededException e)
      Reports a RenderDepthExceededException to ErrorReportCollector.

      No new per-GUI "failed/closed" bookkeeping is added here: the exception is always thrown from the same guard site with a stable top stack frame (the guard name and depth vary in the message, not in the class/method/line the stack trace records), so ErrorReportCollector's own fingerprint-based dedup already coalesces repeated occurrences -- e.g. the same over-deep tree being rebuilt on every setState() call -- into ONE report with an incrementing count, rather than one report per frame.

      Parameters:
      e - the depth-guard trip to report
    • flush

      public void flush()
      Runs every pending task immediately (blocking until complete).

      It drains the whole queue, ignores the per-frame task budget, and is intended for tests only.

      Note: this method may only be called on the main thread.

      Gate-2 Codex finding, PR #478 round 3: a RenderDepthExceededException from a queued task now reaches ErrorReportCollector here too, matching executeFrame() and runOnMainThread(java.lang.Runnable)'s on-main-thread branch -- neither of those catches ran for a task drained through this method. Still re-thrown immediately afterward, preserving this method's own documented synchronous-drain contract (and, unchanged from before this fix, still skipping the isScheduled.set(false) below on that path -- not a new gap this fix introduces or is scoped to close).

      Throws:
      IllegalStateException - if not on the main thread
    • cancelAll

      public void cancelAll()
      Cancels every pending task.