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), which as of 6.3.1 answers from the UltiCloud protocol v2 message registry — so a module can never silently take over command.execute or any other message type of the framework's protocol; 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 (<module>.<action>) is served by the protocol v2 module.request handler, which looks the type up in this registry, not through a second dispatch mechanism (01-CONTEXT D-10/D-11). The protocol v1 inbound dispatch that used to consult it was removed in 6.3.1.

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.

Result shapes

A responder answers with a future of any JsonElement: an object, an array, a primitive, or "no content". The future completing with Java null or with JsonNull means "answered successfully, no content", and dispatch(java.lang.String, com.google.gson.JsonObject, java.lang.String) then settles with JsonNull.INSTANCE; it never settles successfully with a Java null. Only a responder that returns no future at all (null instead of a future), throws, or whose future completes exceptionally fails the request. Over UltiCloud protocol v2 the result becomes the data of an ok module.response unchanged, including data: null. A responder written against 6.3.0, typed Function<JsonObject, CompletableFuture<JsonObject>>, keeps compiling and behaving as before, except that completing with null is now a success with no content rather than a failure.

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,? extends CompletableFuture<? extends com.google.gson.JsonElement>> 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 command.execute 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.

      UltiCloud protocol v2 module types

      A module that answers UltiCloud protocol v2 module.request messages registers its responder under <prefix>.<action>, where the prefix is either the module's own name lower-cased (as in 6.3.0) or its UltiCloud module identifier. UltiCloud addresses the module by its identifier: its identify-string, lower-case (ModuleIds), which for some modules differs from the name. The v2 bridge takes module and action from the request payload and dispatches only to a responder of the module whose identifier equals module and registered under <module>.<action> or <its name lower-cased>.<action>; any other request is answered module_not_found and never dispatched (D-25, contract answer 11). An action therefore needs no namespace of its own: the module is the namespace. The identifier is taken from the recorded module instance (see ownerModule); a responder with no instance recorded or listed, or whose module declares no usable identify-string, keeps the 6.3.0 behaviour: its owner name lower-cased stands for the identifier.

      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: any JsonElement (object, array or primitive), or null / JsonNull for "answered, no content"; see "Result shapes" in the class documentation. Returning null instead of a future is a failure
      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,? extends CompletableFuture<? extends com.google.gson.JsonElement>> 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; its result shapes are those of registerResponder(String, Function, String)
      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
    • ownerModuleOf

      public String ownerModuleOf(String messageType)
      The module name owning the responder registered for the exact string messageType, as the registration recorded it. A read-only view of the ownership a dispatch of this type would use, so a caller can decide whether a request may reach it before admitting it.
      Parameters:
      messageType - the message type to look up, possibly null
      Returns:
      the owner module name, or null when no responder owns this exact type
      Since:
      6.3.1
    • moduleIdentifierOf

      @Internal public String moduleIdentifierOf(String ownerModule)
      The UltiCloud module identifier of the module that owns responders under ownerModule (22-26 fix round): the identify-string of the recorded module instance, or of the copy listed as loaded under that name, lower-cased (ModuleIds.of(UltiToolsPlugin)); when neither gives one, the owner name itself through the same derivation, as in 6.3.0. Internal, not for module use.
      Parameters:
      ownerModule - an owner module name as ownerModules() lists it
      Returns:
      the identifier, or null when the module owns no responder or none can be derived
      Since:
      6.3.1
    • moduleRequestType

      @Internal public String moduleRequestType(String moduleIdentifier, String action)
      The registered message type a UltiCloud module.request for moduleIdentifier and action is dispatched to (22-26 fix round), or null when there is none: the type <moduleIdentifier>.<action>, or <owner name lower-cased>.<action> as 6.3.0 registered it, and only when the module owning that responder has moduleIdentifier as its identifier (moduleIdentifierOf(String)). The identifier form wins when both exist. Compared lower-case. Internal, not for module use.
      Parameters:
      moduleIdentifier - the request's module
      action - the request's action
      Returns:
      the type to dispatch, or null when no responder of that module serves the action
      Since:
      6.3.1
    • identifierOf

      private static String identifierOf(PanelResponderRegistry.ResponderEntry entry)
      The identifier of an entry's module; see moduleIdentifierOf(String).
    • ownerModules

      public List<String> ownerModules()
      The distinct module names owning at least one registered responder, in ascending name order. A read-only snapshot: later registrations and removals do not change the returned list. One name appears once however many types the module owns, and a module with no responder at the moment of the call does not appear at all.
      Returns:
      an unmodifiable list of owner module names; empty when no responder is registered
      Since:
      6.3.1
    • dispatch

      public CompletableFuture<com.google.gson.JsonElement> 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 JsonElement other than JsonNull — that value, whatever its shape (object, array or primitive);
      • the responder's future completes with null or JsonNull — JsonNull.INSTANCE, a success with no content;
      • 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 instead of a future — 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, never a Java null (JsonNull.INSTANCE for "no content"), or exceptionally with a failure describing what went wrong
    • dispatch

      @Internal public CompletableFuture<com.google.gson.JsonElement> dispatch(String messageType, com.google.gson.JsonObject data, String requestId, Runnable workEnded)
      dispatch(String, JsonObject, String), also telling the caller when the module's work for the request has ended (22-23 second verification): workEnded runs exactly once, after the responder call returned and the future it returned completed, or as soon as the request settles without the responder having started (no responder, the queue full, the registry shut down, the deadline or a cancel while it waited). Unlike the returned future it does not run at the deadline while the responder is still running. It never runs for a responder whose future never completes. It runs on whichever thread ends the work; a workEnded that throws is ignored. Internal, not for module use.
      Parameters:
      messageType - see dispatch(String, JsonObject, String)
      data - see dispatch(String, JsonObject, String)
      requestId - see dispatch(String, JsonObject, String)
      workEnded - runs once when the module's work for this request has ended; null for none
      Returns:
      see dispatch(String, JsonObject, String)
      Since:
      6.3.1
    • runQuietly

      private static void runQuietly(Runnable hook)
    • 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.