Class ServerMonitorManager

java.lang.Object
com.ultikits.ultitools.manager.ServerMonitorManager

@Internal public class ServerMonitorManager extends Object
Server monitor manager. Responsible for collecting server status information and sending it via WebSocket.
  • Field Details

    • webSocketClient

      private UltiPanelWebSocketClient webSocketClient
    • scheduler

      private ScheduledExecutorService scheduler
      Send thread pool. Must not be final: stopMonitoring() shuts it down, and once a ScheduledExecutorService is shut down it is permanently dead. Logging out and back in is a perfectly normal path, and at that point startMonitoring() must obtain a usable pool -- otherwise scheduleAtFixedRate throws RejectedExecutionException directly.
    • isMonitoring

      private boolean isMonitoring
    • tickCount

      private int tickCount
    • lastTick

      private long lastTick
    • tpsHistory1m

      private final long[] tpsHistory1m
    • tpsHistory5m

      private final long[] tpsHistory5m
    • tpsHistory15m

      private final long[] tpsHistory15m
    • historyIndex

      private int historyIndex
    • lastCpuUsage

      private volatile double lastCpuUsage
    • SNAPSHOT_INTERVAL_TICKS

      private static final long SNAPSHOT_INTERVAL_TICKS
      Sampling period for world/player/plugin status, in ticks. 100 ticks = 5 seconds, matching batch_update's send cadence.
      See Also:
    • LOG_FLUSH_POLL_INTERVAL_MS

      private static final long LOG_FLUSH_POLL_INTERVAL_MS
      Polling granularity for maybeSendLogsOnly()'s scheduled check, in milliseconds (Gate-2 finding, round 11). Narrowed from 1 second to bound the worst-case overshoot past a configured batchConfig.interval to roughly this value, rather than up to a full second -- see the call site's own comment for the numeric evidence and the reasoning for not instead dynamically rescheduling this task to track the transmitter's own live interval.
      See Also:
    • stateSnapshot

      private volatile ServerMonitorManager.ServerStateSnapshot stateSnapshot
      A server-state snapshot sampled on the main thread; the async send thread only ever reads it.

      This is the entire point of issue #179. Before this field existed, sendBatchUpdate ran on a plain ScheduledThreadPool but called Bukkit.getWorlds(), world.getLoadedChunks(), Bukkit.getOnlinePlayers(), player.getLocation() directly from there -- all mutable world state Paper explicitly does not support touching from an async thread, surfacing as intermittent concurrent-modification exceptions or torn reads.

      TPS/CPU sampling in this same class already correctly hopped over to runTaskTimer, showing the contract was recognized at the time -- just only half applied. This field fills in the other half, following the exact same pattern.

      The cost is that data can be stale by up to one sampling period (5 seconds). This is a deliberate choice: the alternative would have the async thread synchronously wait on the main thread via callSyncMethod().get(), which would make monitoring's liveness depend on the main thread's health -- precisely when the server is lagging is exactly when monitoring most needs to still be able to speak.

    • tpsTask

      private org.bukkit.scheduler.BukkitTask tpsTask
      The two main-thread scheduled tasks; stopMonitoring() needs to be able to cancel them.
    • snapshotTask

      private org.bukkit.scheduler.BukkitTask snapshotTask
    • lastLogFlushMs

      private final AtomicLong lastLogFlushMs
      Wall-clock timestamp of the last time queued logs were actually drained into a batch_update message, in external-drain mode (Gate-2 finding: with monitoring enabled -- the shipped default -- sendBatchUpdate() runs on its own hardcoded 5-second tick, completely independent of UltiPanelLogTransmitter's own configured batch-send interval. Before this field existed, that meant the panel's live batchConfig.interval action (#432) had NO observable effect whenever monitoring was on, because the monitor drained the queue every 5 seconds regardless of what interval was configured. Gating the drain itself by this timestamp makes the configured interval govern how often logs actually go out, even though sendBatchUpdate() itself still ticks every 5 seconds for status/metrics). Starts at 0 so the very first tick after startMonitoring() always drains.

      AtomicLong, not a plain volatile long (Gate-2 finding, round 4): two independently-scheduled tasks (sendBatchUpdate()'s 5-second tick and maybeSendLogsOnly()'s 1-second tick) can become due in the SAME window (e.g. a 1000ms configured interval), and volatile only guarantees visibility, not that a "read, compare, then write" sequence is atomic -- both threads could pass the elapsed-time check before either updates the field, both call UltiPanelLogTransmitter.drainQueue(int) concurrently against the same queue, and split one batch of records across two separate log_batch/batch_update frames with interleaved poll() calls, scrambling delivery order across the two frames. claimLogFlushWindow(long, int) uses AtomicLong.compareAndSet(long, long) so only ONE of the two competing callers ever wins a given window; the loser skips draining entirely rather than draining a partial, racing subset.

    • logDrainLock

      private final Object logDrainLock
      Guards the ACTUAL drain-and-send operation (drainAndSendLogsOnly(com.ultikits.ultitools.manager.UltiPanelLogTransmitter)) across all THREE paths that can trigger one: sendBatchUpdate()'s 5-second tick, maybeSendLogsOnly()'s 1-second tick, and drainLogsNow()'s size-triggered, event-driven call (Gate-2 finding, round 7). claimLogFlushWindow(long, int)'s CAS only arbitrates between the two TIME-based paths against each other; drainLogsNow() deliberately does not participate in that CAS at all (it is not time-gated), so without a separate mutual-exclusion lock it could run concurrently with whichever time-based path just won the CAS, and the two would poll() the same queue at once, splitting one logical batch across two frames with no ordering guarantee between them. This lock is orthogonal to the CAS: the CAS decides WHETHER a time-based path may proceed at all; this lock decides that only ONE drain (whichever path triggered it) is ever in flight at a time.
    • diskUsageRoot

      private File diskUsageRoot
      The file whose filesystem backs the diskUsage metric -- the server root (the working directory the Paper process was started in), matching FileOperationManager's own serverRoot convention. Package-private and mutable only so tests can point it at a controlled location; production code never reassigns it (D-14, #436).
    • totalSpaceReader

      private ToLongFunction<File> totalSpaceReader
      Seam over File.getTotalSpace(), so "the filesystem reports zero total space" (D-14) is deterministic in tests without needing an actual zero-capacity filesystem. Defaults to the real method reference.
    • freeSpaceReader

      private ToLongFunction<File> freeSpaceReader
      Seam over File.getFreeSpace() (WR-03) -- includes space reserved for the filesystem's own root/superuser allocation, matching what df's own Use% column treats as "used" (total minus this value). Distinct from usableSpaceReader (File.getUsableSpace()), which EXCLUDES that reservation -- the two diverge on a real ext4 volume with a nonzero reserved-block percentage.
    • usableSpaceReader

      private ToLongFunction<File> usableSpaceReader
      Seam over File.getUsableSpace(), same rationale as totalSpaceReader.
  • Constructor Details

    • ServerMonitorManager

      public ServerMonitorManager()
  • Method Details

    • getLogDrainLock

      Object getLogDrainLock()
      Exposes logDrainLock so LogStreamManager.initialize(com.ultikits.ultitools.websocket.UltiPanelWebSocketClient) can wire UltiPanelLogTransmitter.setExternalDrainCoordinationLock(java.lang.Object) to the SAME lock object this class's own drain paths already synchronize on (Gate-2 finding, round 9). Package-private -- this is internal cross-class coordination, not a public API.
    • claimLogFlushWindow

      private boolean claimLogFlushWindow(long now, int intervalMs)
      Atomically claims the current log-flush window if (and only if) intervalMs has elapsed since the last successful claim, advancing lastLogFlushMs to now as part of the same compare-and-set. Returns false without side effects if the interval has not elapsed, OR if a concurrently-running competing task already claimed this exact window first (Gate-2 finding, round 4 -- see lastLogFlushMs's own javadoc).
      Parameters:
      now - the caller's own System.currentTimeMillis() snapshot
      intervalMs - the currently configured batch-send interval
      Returns:
      true only for the caller that should proceed to drain and send
    • setDiskUsageRoot

      void setDiskUsageRoot(File diskUsageRoot)
      Test seam: point the disk-usage reading at a different root (e.g. a real temp directory). Production code never calls this.
    • setDiskSpaceReaders

      void setDiskSpaceReaders(ToLongFunction<File> totalSpaceReader, ToLongFunction<File> freeSpaceReader, ToLongFunction<File> usableSpaceReader)
      Test seam: inject fake total/free/usable-space readers (e.g. to force the zero-total-space outcome deterministically, or to pin a known used-percentage without depending on the real filesystem's actual free space). Production code never calls this.
    • setWebSocketClient

      public void setWebSocketClient(UltiPanelWebSocketClient client)
      Set the WebSocket client.
      Parameters:
      client - the WebSocket client
    • startMonitoring

      public void startMonitoring()
      Start monitoring server status.
    • stopMonitoring

      public void stopMonitoring()
      Stop monitoring.

      Besides shutting down the send thread, the two main-thread scheduled tasks must also be cancelled. This used to be just scheduler.shutdown(), which was enough before sampling moved to the main thread -- the cost of iterating every world lived on the send thread, so shutting down sending removed it too. After the move, not cancelling turns "stop monitoring" into "stop sending, but still iterate every world and chunk every 5 seconds" -- and now on the main thread. See the PR #265 review.

    • cancelTask

      private static void cancelTask(org.bukkit.scheduler.BukkitTask task)
    • sendServerStatus

      public void sendServerStatus()
      Send server status information.
    • extractVersionNumber

      private String extractVersionNumber(String fullVersion)
      Extract the version number.
    • getCPUUsage

      private double getCPUUsage()
      Get the CPU usage (returns the cached value, updated by periodic sampling).
    • sampleCpuUsage

      private void sampleCpuUsage()
      Sample CPU usage and update the cached value.
    • sendServerStatusWithRequestId

      public void sendServerStatusWithRequestId(String requestId)
      Send server status information with a request ID (in response to a backend request).
    • sampleServerState

      private ServerMonitorManager.ServerStateSnapshot sampleServerState()
      Samples a server-state snapshot on the main thread.

      May only be called on the Bukkit main thread. A call from a non-main thread is refused and logged as SEVERE -- this is a second, defensive gate: scheduling already guarantees the thread, but this class's own history is exactly "the contract was recognized, only half applied", and writing the contract into the code is more reliable than writing it into a comment.

      Returns:
      the new snapshot; null if not on the main thread
    • isOnPrimaryThreadOrComplain

      private boolean isOnPrimaryThreadOrComplain()
      The single check point for the thread contract: logs SEVERE and returns false when not on the main thread.

      This class's own history is exactly "the contract was recognized, only half applied", so the contract is written as a runtime-observable signal rather than trusted to a comment -- on a real server, this line appearing in the log is proof that scheduling was broken.

    • refreshStateSnapshot

      void refreshStateSnapshot()
      Entry point for the main-thread scheduled task: samples and publishes the snapshot.

      Package-visible so tests can drive it directly.

    • hasSampledState

      boolean hasSampledState()
      Lets tests assert whether sampling has actually happened yet -- more direct than observing the emitted JSON externally.
    • currentSnapshot

      private ServerMonitorManager.ServerStateSnapshot currentSnapshot()
      Gets the current snapshot for the send thread to use.

      If sampling has never happened even once and the caller happens to be on the main thread, sample once right here -- otherwise the first frame after the connection is established would be all zeroes, and on-demand requests like sendServerStatusWithRequestId would return empty data before monitoring has started.

    • getCurrentServerStatusData

      private com.google.gson.JsonObject getCurrentServerStatusData()
      Gets the current server status data.

      Every Bukkit-derived field comes from the main-thread-sampled snapshot (see stateSnapshot); this method itself may be called from any thread. Runtime memory, JVM uptime, TPS and CPU are not Bukkit state and are read in place -- TPS and CPU are already maintained by the main thread's 1Hz task.

    • sendBatchUpdate

      private void sendBatchUpdate()
      Sends the batch_update message, merging status, metrics, plugins and logs into a single WebSocket frame. Called every 5 seconds; plugins is included once every 12th tick (60 seconds).
    • maybeSendLogsOnly

      private void maybeSendLogsOnly()
      Checks the log-drain interval gate at a LOG_FLUSH_POLL_INTERVAL_MS granularity (narrowed from 1 second in round 11), independent of sendBatchUpdate()'s own fixed 5-second tick (Gate-2 finding, round 3).

      Both this method and sendBatchUpdate() call claimLogFlushWindow(long, int) against the SAME lastLogFlushMs field, so only one of the two competing scheduled tasks ever drains for a given window (Gate-2 finding, round 4 -- see lastLogFlushMs's own javadoc for why a plain volatile long check-then-write was not race-safe here).

      Sends a batch_update message carrying ONLY data.logs when logs are both due and non-empty -- not a new message type: the Worker's own handler (`websocket-server.ts` `case 'batch_update'`) already guards every field with if (batch.X), so a partial batch_update is an already-supported shape, not a protocol change.

    • drainLogsNow

      void drainLogsNow()
      Immediately drains and sends whatever is currently queued, without waiting for either sendBatchUpdate()'s 5-second tick or maybeSendLogsOnly()'s 1-second tick (Gate-2 finding, round 6). Registered as UltiPanelLogTransmitter's externalSizeThresholdCallback from LogStreamManager.initialize(com.ultikits.ultitools.websocket.UltiPanelWebSocketClient): under external drain mode, addToBatch()'s own size-threshold trigger is otherwise silently swallowed by sendBatch()'s own early return, so a configured batchSize never shortened delivery latency while monitoring was active, and a sustained burst could fill the transmitter's queue cap and start discarding old entries despite repeatedly crossing the threshold.

      Deliberately does NOT go through claimLogFlushWindow(long, int)'s interval gate: that gate rate-limits the two TIME-based tasks against the configured interval, but this trigger is queue-DEPTH-based, a different reason to drain, and the whole point is to bypass waiting for the interval when the queue is already at capacity risk. drainQueue is safe to call concurrently with the other two tasks (ConcurrentLinkedQueue#poll is atomic per call) -- a near-simultaneous interval-triggered drain and this size-triggered one would, at worst, split one burst across two frames rather than lose or duplicate any entry. lastLogFlushMs is still advanced afterwards so the interval-based tasks do not immediately re-fire for the records this call already sent.

    • drainAndSendLogsOnly

      private void drainAndSendLogsOnly(UltiPanelLogTransmitter transmitter)
      Drains up to transmitter's configured batchSize and, if non-empty, sends a batch_update message carrying ONLY data.logs -- shared by maybeSendLogsOnly() and drainLogsNow() (Gate-2 finding, round 6 extraction). Not a new message type: the Worker's own handler (`websocket-server.ts` `case 'batch_update'`) already guards every field with if (batch.X), so a partial batch_update is an already-supported shape, not a protocol change. Callers are responsible for their own connectivity/capability checks and for advancing lastLogFlushMs.

      Holds logDrainLock for its whole body (Gate-2 finding, round 7): the poll-and-send sequence itself must be mutually exclusive across all three callers, not just gated by claimLogFlushWindow(long, int)'s CAS (which only arbitrates the two TIME-based callers against each other and is not even consulted by drainLogsNow()).

    • getCurrentPluginArray

      private com.google.gson.JsonArray getCurrentPluginArray()
      Gets the current plugin list array.

      Taken from the main-thread-sampled snapshot. Bukkit.getPluginManager().getPlugins() likewise should not be iterated from an async thread.

    • getCurrentMetricsData

      private com.google.gson.JsonObject getCurrentMetricsData()
      Gets the current performance metrics data.

      Bukkit-derived fields come from the snapshot; Runtime memory and TPS are computed in place.

    • computeDiskUsage

      private double computeDiskUsage()
      The used percentage of the filesystem holding diskUsageRoot, matching df's own Use% convention (WR-03) rather than a straight (total - usable) / total: used = total - free (via freeSpaceReader, File.getFreeSpace() -- INCLUDES space reserved for the filesystem's own root/superuser allocation), and the percentage is used / (used + avail) with avail from usableSpaceReader (File.getUsableSpace() -- EXCLUDES that reservation). The earlier (total - usable) / total formula measured roughly one percentage point higher than df on a real ext4 volume with its default ~5% reserved-block allocation, at or past this metric's own documented one-point UAT tolerance -- see this plan's gate record for the real-machine measurement. Rounded to two decimals exactly like memoryUsage above (D-14, #436). A filesystem reporting zero total space, or a used+avail denominator of zero, is a stated outcome -- 0.0 -- not a division error.
    • countEnabledPlugins

      static int countEnabledPlugins(com.google.gson.JsonArray plugins)
      Counts entries in plugins whose enabled field is true -- the per-plugin flag sampleServerState() already captures for every installed plugin (D-14, #437: enabledPlugins must count plugins that are enabled, not every plugin installed). Package-private and pure so it is unit-testable directly against a synthetic JsonArray without needing a multi-plugin MockBukkit fixture.
      Parameters:
      plugins - the snapshot's plugin array; each element is expected to be a JsonObject carrying a boolean enabled field
      Returns:
      the number of entries whose enabled field is true; 0 for an empty array
    • sendMetricsData

      public void sendMetricsData()
      Sends performance metrics data (standalone message, for on-demand requests).
    • sendMetricsDataWithRequestId

      public void sendMetricsDataWithRequestId(String requestId)
      Sends performance metrics data with a request ID (in response to a backend request).
    • updateTpsAndCpu

      private void updateTpsAndCpu()
      Updates the TPS calculation + CPU sampling -- runs once per second (20 ticks).
    • getCurrentTPS

      public double getCurrentTPS()
      Get current 1-minute average TPS.
      Returns:
      the 1-minute average TPS
      Since:
      6.2.3
    • calculateTPS

      private double[] calculateTPS()
      Calculates TPS -- returns the [1-minute, 5-minute, 15-minute] averages.
    • calculateAverageTPS

      private double calculateAverageTPS(long[] history, int count)
      Calculates the average of a history-of-TPS array.
    • calculateRealtimeTPS

      private double calculateRealtimeTPS()
      Calculates the realtime TPS (based on the most recent history entry).
    • sendPlayerEvent

      public void sendPlayerEvent(String eventType, org.bukkit.entity.Player player, com.google.gson.JsonObject additionalData)
      Sends a player event.
    • isMonitoring

      public boolean isMonitoring()