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

      @Deprecated(since="6.3.1", forRemoval=true) public void setWebSocketClient(UltiPanelWebSocketClient client)
      Deprecated, for removal: This API element is subject to removal in a future version.
      There is no replacement: the framework never calls it, and its own UltiCloud session (protocol 2.0.0) carries the panel requests. Removed in 6.4.0.
      Set the WebSocket client.
      Parameters:
      client - the WebSocket client
      Scheduled removal version:
      6.4.0
    • startMonitoring

      @Deprecated(since="6.3.1", forRemoval=true) public void startMonitoring()
      Deprecated, for removal: This API element is subject to removal in a future version.
      There is no replacement: the framework's own UltiCloud session (protocol 2.0.0) sends the server's status and telemetry itself. Removed in 6.4.0.
      Start monitoring server status: the protocol v1 batch_update sender, its log drain and the two sampling tasks it owns. As of 6.3.1 the framework never calls it; the protocol 2.0.0 session samples through its own telemetry instead.
      Scheduled removal version:
      6.4.0
    • stopMonitoring

      @Deprecated(since="6.3.1", forRemoval=true) public void stopMonitoring()
      Deprecated, for removal: This API element is subject to removal in a future version.
      There is no replacement: the framework's own UltiCloud session (protocol 2.0.0) sends the server's status and telemetry itself. Removed in 6.4.0.
      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.

      Scheduled removal version:
      6.4.0
    • cancelTask

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

      @Deprecated(since="6.3.1", forRemoval=true) public void sendServerStatus()
      Deprecated, for removal: This API element is subject to removal in a future version.
      There is no replacement: the framework's own UltiCloud session (protocol 2.0.0) sends the server's status and telemetry itself. Removed in 6.4.0.
      Send server status information.
      Scheduled removal version:
      6.4.0
    • 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

      @Deprecated(since="6.3.1", forRemoval=true) public void sendServerStatusWithRequestId(String requestId)
      Deprecated, for removal: This API element is subject to removal in a future version.
      There is no replacement: the framework's own UltiCloud session (protocol 2.0.0) sends the server's status and telemetry itself. Removed in 6.4.0.
      Send server status information with a request ID (in response to a backend request).
      Scheduled removal version:
      6.4.0
    • 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.

    • sampleAndPublishState

      boolean sampleAndPublishState()
      Samples and publishes one state snapshot without the v1 connection gate refreshStateSnapshot() applies, so the v2 telemetry path and its tests can publish a fresh sample while no v1 socket exists.

      May only be called on the Bukkit main thread; sampleServerState() refuses any other thread and logs it as SEVERE.

      Returns:
      true when a fresh snapshot was published, false off the main thread or when sampling failed
    • sampleTpsAndCpu

      boolean sampleTpsAndCpu()
      Runs one TPS and CPU sample on the main thread, without starting the v1 sender, the log drain or either sampling task startMonitoring() owns.

      The v2 telemetry session (plan 22-22) schedules this once per second in place of the v1 tpsTask: getCurrentTPS() and sampledCpuPercent() read what it records. It deliberately does not call startMonitoring(), which would also start the v1 batch_update sender and the log drain.

      May only be called on the Bukkit main thread, like every other sample this class takes; any other thread is refused and logged as SEVERE.

      Returns:
      true when the sample ran, false off the main thread
    • primeTpsClock

      boolean primeTpsClock()
      Starts the TPS clock now without recording a sample (plan 22-22 fix): the clock otherwise starts when this object is created, and the v2 sampler first runs 20 ticks after the first connection, which may come long after the plugin enabled; that gap would be recorded as the first TPS sample. CPU is sampled once so the next sample has a value. Main thread only, like sampleTpsAndCpu().
      Returns:
      true when the clock was started, false off the main thread
    • currentStateSnapshot

      Reads the published main-thread sample for the v2 telemetry encoders (AgentTelemetry). Package-private: internal cross-class reading, not a public API, and the v1 send paths keep using currentSnapshot().

      Samples once here when nothing has been published yet and the caller is on the main thread, exactly like currentSnapshot(); otherwise it returns the last publication, which may be up to one sampling period old.

      Returns:
      the latest sampled state, or the empty snapshot before the first sample
    • sampledCpuPercent

      double sampledCpuPercent()
      Returns:
      the process CPU load percentage measured by the 1 Hz main-thread task, unclamped (the contract's encoder applies its own bounds)
    • 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()).

    • sendKeepingUndeliveredLogs

      private void sendKeepingUndeliveredLogs(com.google.gson.JsonObject message, UltiPanelLogTransmitter transmitter, com.google.gson.JsonArray logs)
      Sends a batch_update frame and, when it carried log records and did not go out, hands them back to the transmitter so the next drain sends them before anything newer (#486). The frame did not go out if sendMessage threw -- the exception still propagates to the caller's own handler -- or if the client no longer reports itself connected afterwards, which is what the closed-socket path of sendMessage leaves behind. Called with logDrainLock held, so the hand-back cannot interleave with another drain.
    • 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

      @Deprecated(since="6.3.1", forRemoval=true) public void sendMetricsData()
      Deprecated, for removal: This API element is subject to removal in a future version.
      There is no replacement: the framework's own UltiCloud session (protocol 2.0.0) sends the server's status and telemetry itself. Removed in 6.4.0.
      Sends performance metrics data (standalone message, for on-demand requests).
      Scheduled removal version:
      6.4.0
    • sendMetricsDataWithRequestId

      @Deprecated(since="6.3.1", forRemoval=true) public void sendMetricsDataWithRequestId(String requestId)
      Deprecated, for removal: This API element is subject to removal in a future version.
      There is no replacement: the framework's own UltiCloud session (protocol 2.0.0) sends the server's status and telemetry itself. Removed in 6.4.0.
      Sends performance metrics data with a request ID (in response to a backend request).
      Scheduled removal version:
      6.4.0
    • 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

      @Deprecated(since="6.3.1", forRemoval=true) public void sendPlayerEvent(String eventType, org.bukkit.entity.Player player, com.google.gson.JsonObject additionalData)
      Deprecated, for removal: This API element is subject to removal in a future version.
      There is no replacement: the framework's own UltiCloud session (protocol 2.0.0) reports player events itself. Removed in 6.4.0.
      Sends a player event.
      Scheduled removal version:
      6.4.0
    • isMonitoring

      @Deprecated(since="6.3.1", forRemoval=true) public boolean isMonitoring()
      Deprecated, for removal: This API element is subject to removal in a future version.
      There is no replacement: the framework's own UltiCloud session (protocol 2.0.0) sends the server's status and telemetry itself. Removed in 6.4.0.
      Whether the protocol v1 monitor that startMonitoring() starts is running.
      Scheduled removal version:
      6.4.0