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.

Since:
6.3.0
  • Field Details

    • RESPONDER_TIMEOUT_MILLIS

      static final long RESPONDER_TIMEOUT_MILLIS
      The bounded wall-clock time dispatch(java.lang.String, com.google.gson.JsonObject, java.lang.String) gives a registered responder's future to complete before completing exceptionally on the caller's behalf (D-27). One timeout, applied in exactly one place — inside dispatch — 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. Package-private (not private) so PanelResponderRegistryTest can bound its own wait without duplicating this value.
      See Also:
    • responders

      Pairs a responder function with the module name that registered it. Never exposed outside this class — hasResponder(String)/unregisterAll(String) are the only externally visible views of what this map holds.
    • timeoutScheduler

      private final ScheduledThreadPoolExecutor timeoutScheduler
      Schedules RESPONDER_TIMEOUT_MILLIS's one-timeout-in-one-place enforcement. A dedicated single-thread pool, not a shared framework scheduler, so one slow responder cannot starve unrelated timeout tasks. setRemoveOnCancelPolicy(true) makes a cancelled task — the fast path, when the responder completes before the timeout fires — removed from the queue synchronously inside cancel(), rather than lingering in the queue until its scheduled time; without it a fast responder would still "leak" a queued task for RESPONDER_TIMEOUT_MILLIS. There is no CompletableFuture.orTimeout on the Java 8 bytecode target this framework compiles to (that method is Java 9+), which is why the timeout is hand-scheduled here rather than chained.
  • Constructor Details

    • PanelResponderRegistry

      public PanelResponderRegistry()
  • Method Details

    • createTimeoutScheduler

      private static ScheduledThreadPoolExecutor createTimeoutScheduler()
    • 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.

      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
      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
    • 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
    • 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)
      Invokes messageType's registered responder and returns a future that is already bounded by RESPONDER_TIMEOUT_MILLIS — the single timeout-and-reply-shape point every failure mode routes through (D-27):
      • the responder throws synchronously, before returning a future — caught and becomes a failed future rather than escaping;
      • the responder returns null — treated as an explicit failure, not a NullPointerException;
      • the responder's future never completes — the scheduled timeout task completes this method's returned future exceptionally with PluginModuleException.responderTimedOut(String, String, long);
      • the responder's future completes exceptionally — that exception is relayed directly.
      On the fast path (the responder's future is already complete, or completes before the timeout fires), the scheduled timeout task is cancelled inside the same whenComplete callback that resolves the returned future, so nothing is left pending.
      Parameters:
      messageType - the message type to dispatch, expected to already have a registered responder (hasResponder(String))
      data - the inbound message's data object, passed straight to the responder
      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 completes — 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
    • shutdown

      public void shutdown()
      Shuts down timeoutScheduler (WR-01, 06-REVIEW.md).

      timeoutScheduler is a dedicated single-thread pool per instance — nothing else shares it and nothing else stops it, so without this call a plugin /reload (which constructs a fresh PanelResponderRegistry on every onEnable without ever disposing of the previous one) leaks one more daemon UltiTools-PanelResponderRegistry-Timeout thread per cycle for the life of the process. ScheduledThreadPoolExecutor.shutdownNow(), not the graceful shutdown(), because any timeout task still queued at this point belongs to a responder dispatch this instance is being torn down anyway — there is no in-flight work here worth draining.