Class PanelResponderRegistry

java.lang.Object
com.ultikits.ultitools.websocket.PanelResponderRegistry

public class PanelResponderRegistry extends Object
The single-owner registry a module uses to claim a request/response responder for a panel message type the framework itself does not own (WIRE-16, D-26/D-27).

A module owns at most one responder per message type. Ownership is decided against exactly one source of truth for the framework's own types — PluginInitiationUtils.isFrameworkOwnedType(java.lang.String) — so a module can never silently take over execute_command or any of the framework's other 23 inbound message types; that check runs before the module-owner check, and both refusals throw a typed PluginModuleException naming the offender rather than a raw RuntimeException.

A module-owned type is served from the exact same lookup PluginInitiationUtils #handleInboundMessage already uses for the framework's own 24 types — its unknown-type branch consults this registry, not a second dispatch mechanism (01-CONTEXT D-10/D-11).

Deliberately does not require a <module>:<type> namespace prefix — D-26 rejected that as a cross-repository protocol convention the panel would also have to honour, rather than an in-repo check the framework can enforce alone.

Scheduling contract for responders

  • Thread. A responder is called on one of this registry's own worker threads — never the server main thread, and never the thread reading the panel connection, so a slow call does not hold up other panel messages or the connection's heartbeat. A responder that uses Bukkit API schedules that part onto the main thread itself (for example with Bukkit.getScheduler().runTask) and completes its future from there.
  • Deadline. One deadline of RESPONDER_TIMEOUT_MILLIS starts when the request is admitted (dispatch(java.lang.String, com.google.gson.JsonObject, java.lang.String) is called) and covers waiting for a worker, the call itself and the returned future. When it passes, the request fails with a timeout error; a result that arrives later is dropped and produces no second reply.
  • Bounds. At most RESPONDER_WORKER_THREADS calls run at once and at most RESPONDER_QUEUE_CAPACITY admitted requests wait for a worker; a request beyond that is refused at once with an error reply naming its type and module. Known limitation: if the admitting thread stalls for longer than the deadline between starting a request's deadline and queueing the request, while every worker is blocked, the already-expired request is queued afterwards and holds one queue slot until a worker frees, so a later request can be refused as if the queue were full; no wrong reply is sent.
  • Concurrency. Responders may therefore run concurrently — up to RESPONDER_WORKER_THREADS at once, including several calls of the same responder — and may finish out of request order, so a responder must be thread-safe.
  • No interruption. A call that blocks past its deadline keeps its worker until it returns; the framework never interrupts it, so uninterruptible database or disk I/O in module code is never cut off midway.
  • Context. The call sees the context class loader of the thread that admitted the request and this registry's registration scope (beginRegistrationScope(com.ultikits.ultitools.abstracts.UltiToolsPlugin)) of that thread, and nothing else: no other ThreadLocal, no transaction and no audited-entity user is carried. The worker's own values are restored after every call, so nothing one module's call leaves behind reaches the next call. Worker threads are created lazily on the thread that admits a request (the panel connection's reader thread), so each copies that thread's InheritableThreadLocal values once, at creation; the framework declares none.
  • Data. The responder receives its own copy of the request's data, taken when the request is admitted; it may change that copy freely, and its changes reach neither the caller's object nor the PanelMessageEvent published for the request.
  • Ownership. The responder registered when the request was admitted handles it, even if the type is reassigned or the owning module unloads before the call runs; a module unloading while one of its requests is still waiting or running can therefore see that call.
  • Shutdown. shutdown() settles every admitted request with a failure, runs no request that has not started when it begins (including one a worker takes from the queue while it is in progress) and interrupts no running call.
  • Callbacks. A callback attached to the future dispatch(java.lang.String, com.google.gson.JsonObject, java.lang.String) returns (the framework's reply is one) runs on whichever thread settles that future — a worker, the deadline timer, a module's own thread or the thread calling shutdown() — or at once on the attaching thread when the future is already settled.
Since:
6.3.0
  • Field Details

    • LOGGER

      private static final Logger LOGGER
    • RESPONDER_TIMEOUT_MILLIS

      static final long RESPONDER_TIMEOUT_MILLIS
      The one deadline every admitted request gets (D-27), counted from admission — the moment dispatch(java.lang.String, com.google.gson.JsonObject, java.lang.String) is called — and covering the wait for a worker, the responder's call and its returned future. One deadline, applied in exactly one place, so no responder and no caller implements its own. 3 seconds: long enough for a responder doing real database or disk work, short enough that a human operator watching the panel does not start to think it froze. A call still running when the deadline passes is not interrupted; its later result is dropped. Package-private (not private) so tests can bound their own waits without duplicating this value.
      See Also:
    • RESPONDER_WORKER_THREADS

      static final int RESPONDER_WORKER_THREADS
      How many responder calls run at once, each on its own registry-owned worker thread.
      See Also:
    • RESPONDER_QUEUE_CAPACITY

      static final int RESPONDER_QUEUE_CAPACITY
      How many admitted requests may wait for a free worker before a request is refused.
      See Also:
    • WORKER_KEEP_ALIVE_SECONDS

      private static final long WORKER_KEEP_ALIVE_SECONDS
      How long an idle worker thread is kept before it ends, so an idle registry holds no worker.
      See Also:
    • responders

      Pairs a responder function with the module name (and, when recorded, the module instance) that registered it. Never exposed outside this class — hasResponder(String), unregisterAll(String), unregisterByOwnerInstance(UltiToolsPlugin) and releaseForSupersede(UltiToolsPlugin) are the only externally visible views of what this map holds.
    • timeoutScheduler

      private final ScheduledThreadPoolExecutor timeoutScheduler
      Holds only the deadline tasks: each admitted request's deadline is scheduled here the moment dispatch(java.lang.String, com.google.gson.JsonObject, java.lang.String) admits it. A dedicated single-thread pool, separate from workers, so a responder call that blocks can never delay a deadline. setRemoveOnCancelPolicy(true) makes a cancelled deadline — the fast path, when the request settles before its deadline — leave the queue synchronously inside cancel(), rather than lingering until its scheduled time. There is no CompletableFuture.orTimeout on the Java 8 bytecode target this framework compiles to (that method is Java 9+), which is why the deadline is hand-scheduled here rather than chained.
    • deadlineMillis

      private final long deadlineMillis
      The deadline, in milliseconds, every admitted request gets; RESPONDER_TIMEOUT_MILLIS outside tests.
    • workers

      private final ThreadPoolExecutor workers
      Runs responder calls, separate from timeoutScheduler so a blocked call can never delay a deadline. Bounded threads, a bounded queue and an abort policy; invocations are handed over with execute, never submit, so a deadline can take a not-yet-started invocation back out of the queue with ThreadPoolExecutor.remove(Runnable).
    • queueCapacity

      private final int queueCapacity
      How many admitted requests may wait for a free worker; named in a refusal.
    • admitted

      private final Set<PanelResponderRegistry.Invocation> admitted
      Every admitted request whose outcome has not settled yet; shutdown() settles them. An invocation leaves this set through the completion hook registered at admission.
    • shutDown

      private volatile boolean shutDown
      Set first thing in shutdown(); a request admitted afterwards fails at once, and a worker reading it at the start of a call settles that request instead of running it.
    • registrationScopeOwner

      private final ThreadLocal<UltiToolsPlugin> registrationScopeOwner
      The module instance the framework is loading on this thread, if any (#506): set by beginRegistrationScope(UltiToolsPlugin) while a module's container refreshes and its registerSelf() runs, and recorded on every responder registered meanwhile without an explicit owner instance.
  • Constructor Details

    • PanelResponderRegistry

      public PanelResponderRegistry()
      Creates a registry with the shipped deadline (RESPONDER_TIMEOUT_MILLIS) and worker bounds (RESPONDER_WORKER_THREADS, RESPONDER_QUEUE_CAPACITY).
    • PanelResponderRegistry

      PanelResponderRegistry(long deadlineMillis, int workerThreads, int queueCapacity)
      Test seam: a registry with its own deadline and worker bounds.
      Parameters:
      deadlineMillis - the deadline every admitted request gets, in milliseconds
      workerThreads - how many responder calls run at once
      queueCapacity - how many admitted requests may wait for a free worker
  • Method Details

    • createTimeoutScheduler

      private static ScheduledThreadPoolExecutor createTimeoutScheduler()
    • createWorkers

      private static ThreadPoolExecutor createWorkers(int workerThreads, int queueCapacity)
    • registerResponder

      public void registerResponder(String messageType, Function<com.google.gson.JsonObject,CompletableFuture<com.google.gson.JsonObject>> responder, String ownerModule)
      Registers responder as the single owner of messageType.

      Validates all three arguments first. Then checks PluginInitiationUtils.isFrameworkOwnedType(String) before checking module ownership — deliberately in that order, so a module attempting execute_command is told the framework owns it, not that some other module does. Finally uses an atomic putIfAbsent-shaped check so a collision — with the framework, with another module, or with the same module registering twice — always throws rather than silently displacing an existing registration.

      responder is called on one of this registry's worker threads, never the server main thread, within one deadline of RESPONDER_TIMEOUT_MILLIS from admission, and possibly concurrently with itself and other responders (up to RESPONDER_WORKER_THREADS calls at once, finishing out of request order), so it must be thread-safe; Bukkit API work belongs on the main thread, scheduled by the responder itself. See the class documentation for the full scheduling contract.

      Parameters:
      messageType - the exact message type string this responder will serve, matched by String.equals — no case folding, no Unicode normalization
      responder - the responder function, invoked with the inbound message's data and returning a CompletableFuture that resolves the reply
      ownerModule - the name of the module registering this responder. The responder is recorded against a module instance (#506, #562 item 1): the module the framework is loading on this thread, if any; otherwise the copy listed as loaded under ownerModule (the first listed when more than one is); otherwise none, and it is filed under the name only. Prefer registerResponder(String, Function, String, UltiToolsPlugin) when the module instance is at hand
      Throws:
      IllegalArgumentException - if messageType or ownerModule is null/empty, or responder is null
      PluginModuleException - if the framework already owns messageType, or another registration (including the same module's own prior registration) already claims it
    • registerResponder

      public void registerResponder(String messageType, Function<com.google.gson.JsonObject,CompletableFuture<com.google.gson.JsonObject>> responder, String ownerModule, UltiToolsPlugin ownerInstance)
      Same as registerResponder(String, Function, String), and additionally records the module instance that owns the responder (#506), so unregisterByOwnerInstance(UltiToolsPlugin) releases exactly that instance's responders whatever name they were filed under. Two copies of one module share a name; only the instance tells a superseded copy's responders from its replacement's.

      responder is called on one of this registry's worker threads under the same scheduling contract as registerResponder(String, Function, String): a deadline from admission, possibly concurrent calls finishing out of order, so it must be thread-safe.

      Parameters:
      messageType - the exact message type string this responder will serve
      responder - the responder function
      ownerModule - the name of the module registering this responder
      ownerInstance - the module instance registering it; null records the module the framework is loading on this thread, if any (see beginRegistrationScope(UltiToolsPlugin)), otherwise the copy listed as loaded under ownerModule (PluginManager.findRegistrationOwner(String)), which is exactly registerResponder(String, Function, String)
      Throws:
      IllegalArgumentException - if messageType or ownerModule is null/empty, or responder is null
      PluginModuleException - if the framework already owns messageType, or another registration already claims it
      Since:
      6.3.0
    • listedCopyNamed

      private static UltiToolsPlugin listedCopyNamed(String moduleName)
      The copy listed as loaded under moduleName, which a registration made by name outside every load scope belongs to (#562 item 1); null before the framework is running.
    • unregisterAll

      public void unregisterAll(String moduleName)
      Removes every responder moduleName owns — the mirror of EventBus.unregisterAll(String)'s one-String-parameter void signature and iterate-and-remove-by-owner shape, placed adjacent to the two existing eventBus.unregisterAll(...) call sites in PluginManager rather than a new lifecycle mechanism.

      A module that registered nothing is a no-op: nothing is removed and nothing is thrown. moduleName == null is also a no-op.

      Parameters:
      moduleName - the module whose responders should be removed, possibly null
    • unregisterByOwnerInstance

      public void unregisterByOwnerInstance(UltiToolsPlugin ownerInstance)
      Removes every responder registered with ownerInstance as its owner (#506), whatever module name it was filed under. A responder registered with a name only is not matched here; unregisterAll(String) releases those.
      Parameters:
      ownerInstance - the module instance being unloaded; null is a no-op
      Since:
      6.3.0
    • releaseForSupersede

      @Internal public Runnable releaseForSupersede(UltiToolsPlugin ownerInstance)
      Releases every responder recorded against ownerInstance and returns the action that gives them back (#562). PluginManager calls this for a loaded copy of a module just before a newer copy of the same module builds its container and runs registerSelf(), so the newer copy can claim the message types the older copy held: while both copies exist, a type belongs to exactly one of them, and the older copy holds none of its types while the newer copy registers. When the newer copy fails to load, the framework first releases what the failed copy registered and then runs the returned action, which puts each released responder back under its type, unchanged; when the newer copy loads, the action is dropped and the older copy is unloaded. Running the action more than once restores nothing more.

      Each responder is removed only while it is still the exact registration this call matched, and put back only while its type is free: a type another registration claimed in between keeps that registration, and one WARNING names the type and the module that lost it -- a copy never takes over, or keeps, a type another copy holds. Intended for PluginManager, not for module authors.

      Parameters:
      ownerInstance - the loaded copy being superseded; null releases nothing
      Returns:
      the action that restores what this call released
      Since:
      6.3.0
    • restore

      private void restore(Map<String,PanelResponderRegistry.ResponderEntry> released)
    • beginRegistrationScope

      public void beginRegistrationScope(UltiToolsPlugin owner)
      Attributes every responder registered on this thread, until endRegistrationScope(), to owner when the registration names no owner instance itself (#506). The framework opens this scope while it loads a module -- around its container refresh and its registerSelf() -- so a responder a module registers while it loads, through registerResponder(String, Function, String), is released with that module instance. Intended for PluginManager, not for module authors. Scopes do not nest.
      Parameters:
      owner - the module instance being loaded
      Since:
      6.3.0
    • endRegistrationScope

      public void endRegistrationScope()
      Ends the scope started by beginRegistrationScope(UltiToolsPlugin) on this thread.
      Since:
      6.3.0
    • hasResponder

      public boolean hasResponder(String messageType)
      Whether a responder is currently registered for the exact string messageType.
      Parameters:
      messageType - the message type to check, possibly null
      Returns:
      true if a responder owns this exact type
    • dispatch

      public CompletableFuture<com.google.gson.JsonObject> dispatch(String messageType, com.google.gson.JsonObject data, String requestId)
      Admits a request for messageType's registered responder and returns its outcome at once, without waiting for the responder: admission captures the responder registered now (and its owner), a copy of data, the calling thread's context class loader and this registry's registration scope on the calling thread, starts the request's one deadline of RESPONDER_TIMEOUT_MILLIS, and queues the call for a worker thread. The returned future settles exactly once — the single reply-shape point every outcome routes through (D-27):
      • the responder's future completes with a value — that value;
      • the responder throws, including a checked exception thrown sneakily or a linkage error from a closed module class loader — that throwable;
      • the responder returns null, or its future completes with null — an explicit IllegalStateException naming the type and owning module;
      • the responder's future completes exceptionally — that exception, relayed;
      • the deadline passes while the request waits, runs or awaits its future — PluginModuleException.responderTimedOut(String, String, long) naming the owner admitted with the request; a request still waiting is taken out of the queue and never runs, and a later result is dropped;
      • RESPONDER_WORKER_THREADS calls are running and RESPONDER_QUEUE_CAPACITY requests are waiting — at once, a RejectedExecutionException naming the type, the owning module and the bound;
      • the registry is shut down, before or after admission — a RejectedExecutionException stating so;
      • the caller cancels the returned future — cancelled; a request still waiting leaves the queue and never runs.
      Every settlement frees the request's queue slot and deadline. This method never throws. Callbacks attached to the returned future run on whichever thread settles it, or at once on the attaching thread if it is already settled.
      Parameters:
      messageType - the message type to dispatch, expected to already have a registered responder (hasResponder(String))
      data - the inbound message's data object, possibly null; the responder receives a copy taken when this method is called, so its changes never reach data
      requestId - the request's correlation id — not used by dispatch itself, but accepted so the call site does not have to separately track it; reserved for the caller assembling the outbound reply
      Returns:
      a future that always settles — successfully with the responder's result, or exceptionally with a failure describing what went wrong
    • pendingTimeoutTaskCountForTesting

      int pendingTimeoutTaskCountForTesting()
      Test-only accessor for how many timeout tasks are still queued. Proves the fast path in dispatch(java.lang.String, com.google.gson.JsonObject, java.lang.String) leaves nothing pending, rather than merely proving a reply arrived.
      Returns:
      the number of scheduled-but-not-yet-run-or-cancelled timeout tasks
    • isTimeoutSchedulerShutdownForTesting

      boolean isTimeoutSchedulerShutdownForTesting()
      Test-only accessor for whether timeoutScheduler has been shut down — proves shutdown() actually stops the dedicated timeout thread (WR-01), rather than merely proving the method exists and does not throw.
      Returns:
      true if timeoutScheduler has been shut down
    • isWorkerPoolShutdownForTesting

      boolean isWorkerPoolShutdownForTesting()
      Test-only accessor for whether workers has been shut down.
      Returns:
      true if the worker executor has been shut down
    • queuedInvocationCountForTesting

      int queuedInvocationCountForTesting()
      Test-only accessor for how many admitted invocations are waiting for a free worker.
      Returns:
      the number of queued, not-yet-started invocations
    • shutdown

      public void shutdown()
      Shuts this registry down (WR-01, 06-REVIEW.md): stops the deadline timer and the workers and settles every admitted request.

      Both executors are owned by this instance — nothing else shares or stops them — so without this call a plugin /reload (which constructs a fresh PanelResponderRegistry on every onEnable) would leak their threads for the life of the process. The deadline timer is stopped with ScheduledThreadPoolExecutor.shutdownNow(): it holds only deadline tasks, and every request they belong to is settled here anyway. Requests still waiting for a worker are taken out of the queue and never run; a request a worker takes after this method began, before the queue is emptied, is settled with the same failure by that worker instead of being run. The workers are stopped with the graceful ThreadPoolExecutor.shutdown(), never shutdownNow(): a running module call is not interrupted, it returns on its own daemon thread and its result is dropped. Every admitted request not yet settled fails with a RejectedExecutionException stating that the registry shut down before its responder completed, and a request admitted afterwards fails the same way at once.