Class CloudSession

java.lang.Object
com.ultikits.ultitools.utils.CloudSession

final class CloudSession extends Object
The single owner of one UltiCloud login's entire lifetime.

A session owns the token, the poll and refresh schedulers it created, the panel WebSocket client, the reconnect backoff state, and the panel-manager wiring (D-16). login constructs a new session via startNew(); logout replaces the current one the same way. Everything the replaced session started -- an in-flight poll, an in-flight refresh, a reconnect attempt -- lapses on its own once invalidate() has run, because commit(TokenEntity) and every gate this class exposes check this specific instance's invalidated flag, never a shared counter another session's activity could also advance.

This replaces the credential-generation convention CloudAuthManager used before 6.3.0. That convention's own javadoc stated the invariant this class now enforces, and is worth quoting verbatim because the reason has not changed, only the mechanism that satisfies it:

cancellation is not invalidation. stopTokenRefreshScheduler() and stopPolling() both use cancel(false) plus shutdown(), and both only promise not to schedule a new execution -- neither constrains a task that has already entered an HTTP request. [...] every in-flight asynchronous credential operation records the generation it saw when it started, and compares against it [...] before committing its result; the teardown path [...] advances the generation, so any late-arriving result is discarded unconditionally.
A generation counter can only tell a late result that it is late; it cannot stop the scheduler, the WebSocket client, or the panel-manager wiring an old login started from outliving the credential that authorised them. Session identity can, and does: invalidating a session tears down everything it owns in one call, so no separate teardown-ordering discipline is needed for any of the five timing windows issue #298 named.

Global lock order (16-10 gap-closure addendum, issues #465/#466). Four locks are involved across this class, CloudAuthManager and PluginInitiationUtils, and every method that takes more than one of them must acquire them in this fixed order -- outer to inner:

  1. CloudAuthManager.class -- held for the duration of CloudAuthManager.logout() only; never nested with any of the three locks below at the same time in the current code (its own call into invalidate() and into the synchronized (CloudSession.class) block inside PluginInitiationUtils#doDisableCloud run sequentially, not nested), but documented here so a future change that DOES nest it keeps this same relative order rather than inventing one.
  2. CloudSession.class -- held by startNew() and by CloudAuthManager.login(java.lang.Runnable, java.util.function.Consumer<java.lang.Long>, java.lang.Runnable, java.util.function.Consumer<java.lang.String>, java.util.function.Consumer<java.lang.String>) (16-10 gap-closure addendum, issue #466) for its own re-check-through-replacement region.
  3. a specific session's own monitor (synchronized (this) / synchronized(session)) -- held by commit(TokenEntity), invalidate(), startPolling(java.lang.String, java.util.function.Consumer<com.ultikits.ultitools.entities.TokenEntity>), the two scheduler start/stop pairs, and by every caller in PluginInitiationUtils that needs to read-then-act on a session's currency (reinitWebSocket's second confirmation, activateCloudIfCurrent, doDisableCloud's two global-manager steps).
  4. LOG_WIRING_LOCK (16-10 gap-closure addendum, issue #465) -- the innermost lock, held only inside shutdownLogStreamManager() and inside wireManagers()'s own LOGS branch, both of which are themselves only ever reached from within a synchronized (this) region (invalidate() and initializeManagers() respectively) -- so this lock is never acquired without the session monitor already held, and never used to acquire anything further.
startNew()'s own order -- CloudSession.class outer, the replaced session's monitor inner -- is the anchor every other method's order is chosen to match; inverting it anywhere (acquiring a session monitor first, then trying to enter a synchronized (CloudSession.class) block while still holding it) is exactly the AB-BA deadlock issue #466 fixed. No method in this package acquires a session monitor and then blocks trying to acquire CloudSession.class it does not already hold.
Since:
6.3.0
  • Field Details

    • GSON

      private static final com.google.gson.Gson GSON
    • POLL_INTERVAL_MS

      private static final long POLL_INTERVAL_MS
      See Also:
    • MAX_POLL_ATTEMPTS

      private static final int MAX_POLL_ATTEMPTS
      See Also:
    • TOKEN_REFRESH_CHECK_INTERVAL_MS

      private static final long TOKEN_REFRESH_CHECK_INTERVAL_MS
      How often to check if the access token needs refreshing (1 hour)
      See Also:
    • TOKEN_REFRESH_THRESHOLD_SECONDS

      private static final long TOKEN_REFRESH_THRESHOLD_SECONDS
      Refresh the token when it has less than this many seconds remaining (2 hours)
      See Also:
    • OAUTH2_BASIC_AUTH

      private static final String OAUTH2_BASIC_AUTH
      Basic auth header for OAuth2 client credentials (client:112233)
      See Also:
    • MAX_REINIT_ATTEMPTS

      static final int MAX_REINIT_ATTEMPTS
      The global cap on outer reinit attempts. Once exceeded, the state machine enters a terminal state and only /ulticloud login or a restart recovers it. Package-private (not private) so PluginInitiationUtils.reinitWebSocket(CloudSession) can name it in its own "gave up after %d attempts" log line -- the two classes are in the same package.
      See Also:
    • current

      private static volatile CloudSession current
      The current session. Never null -- initialised eagerly so every static facade method on CloudAuthManager always has something to delegate to, even before any login has ever happened.
    • tokenStore

      private final TokenStore tokenStore
    • token

      private volatile TokenEntity token
    • invalidated

      private volatile boolean invalidated
    • predecessorToken

      private volatile TokenEntity predecessorToken
      The nearest ancestor session's last-known token, or null if either there is no ancestor (this is the first session ever created) or that ancestor never itself held a token. Set only by startNew(), consulted only by clearPersisted() as a fallback when THIS session's own token is null -- deliberately never consulted by hasValidToken() or any other connection-status gate (D-18's own invariant is unchanged: a session with no token of its own is not "connected", regardless of what an ancestor once held).

      16-10 gap-closure addendum (round-13 review, PR #464), P1: without this, a session exhausted by reinitWebSocket's budget (which deliberately leaves its own token unset-but-still-in-memory -- clearing the credential is logout's job, not exhaustion's, per hasValidToken()'s own round-10 note) that is then replaced by /ulticloud login via startNew() left its still-valid, still-persisted credential unreachable by any LATER /ulticloud logout call: the new (blank) session has no token of its own, so TokenStore.clearIfMatches(TokenEntity)'s compare-and-delete had nothing to compare against, and logout() reported "nothing to clear" while the old credential survived on disk -- a subsequent restart would reload and reconnect with it despite the operator's explicit logout. Chained forward across however many blank sessions intervene (each inherits the nearest non-null ancestor value, not just its immediate predecessor's own possibly-null field), so the credential stays reachable no matter how many replacements happen before a logout finally runs.

    • pendingAuthenticationCancelled

      private volatile boolean pendingAuthenticationCancelled
      true if invalidate() found -- and cancelled -- a magic-link poll still in flight on this session, and that fact has not yet been reported and reset by clearPersisted().

      Real-machine UAT fix, plan 16-08 (ultitools.ulticloud.logout.neg-mid-poll). Cancelling a pending authentication attempt IS a real logout, even though it never reached commit(TokenEntity) and therefore never set token or predecessorToken -- both of which hasAnythingToClear() used to consult exclusively. Without this field, /ulticloud logout run while a login's magic-link poll is still running (no token committed yet, no predecessor either) reported "Not currently logged in to UltiCloud." even though it genuinely tore the poll down -- observed on a real Paper 1.21.11 server. The safety half of this row (a stale approval landing after this logout must not reactivate the server) was never in question: commit(TokenEntity)'s own invalidated check already rejects it regardless of this field. Only the reported OUTCOME of the logout that cancelled the poll was wrong.

      Captured in invalidate(), immediately before stopPolling() clears pollTask -- reading pollTask any later would always see null and this field could never become true. Reset back to false in clearPersisted()'s own finally block, alongside token and predecessorToken, for the same reason those two are reset there: a second, independent logout() call on this same (already-invalidated) session instance -- e.g. a second /ulticloud logout before any new login replaces it -- must not keep reporting success for a poll this session already reported cancelling once.

    • pollExecutor

      private ScheduledExecutorService pollExecutor
    • pollTask

      private ScheduledFuture<?> pollTask
    • refreshExecutor

      private ScheduledExecutorService refreshExecutor
    • refreshTask

      private ScheduledFuture<?> refreshTask
    • webSocketClient

      private volatile UltiPanelWebSocketClient webSocketClient
      This session's own WebSocket client, or null if none is connected. Task 2 of plan 16-08 moved this off PluginInitiationUtils's static panelWS field -- the client dies with the session that built it (invalidate() disconnects and clears it), so a reconnect-exhaustion callback or a late handshake checked against THIS session can never disagree with what client is actually installed here.
    • backoff

      private final ExponentialBackoffStrategy backoff
      The global budget and backoff for this session's outer reconnection (reinit loop).

      The client's own limit of 5 attempts is a per-instance cap, and PluginInitiationUtils.reinitWebSocket builds a brand-new client instance every time -- so the per-instance cap places no constraint at all on the whole, which is exactly how the loop became unbounded (issue #181). This strategy spans client instances instead; only one successful onOpen resets it. Being a fresh field on every new session is also what makes D-16's "the old session's backoff counter does not carry over" true for free -- a new session's budget starts at zero attempts by construction, with no reset call needed.

    • LOG_WIRING_LOCK

      private static final Object LOG_WIRING_LOCK
      The innermost lock in this class's documented lock order (see the class javadoc) -- guards logStreamOwner only. A dedicated lock object, not this/a session monitor and not CloudSession.class: the ownership check it protects must be readable from shutdownLogStreamManager() (called from invalidate(), under the tearing-down session's own monitor) while comparing against whichever session's monitor wireManagers() (called from initializeManagers(), under the wiring session's own, possibly DIFFERENT, monitor) last held when it wrote it -- two different sessions' monitors can never be compared against each other directly, so the ownership record itself needs its own lock, held only ever as the innermost one (16-10 gap-closure addendum, issue #465).
    • logStreamOwner

      private static CloudSession logStreamOwner
      Which session's log-stream wiring is currently installed on the JVM-wide LogStreamManager singleton, or null if no session has ever wired it (or the last session to do so has already cleanly detached it). Guarded exclusively by LOG_WIRING_LOCK.

      Deliberately tolerant of null, not "unowned means owned by nobody, so skip." A great many call paths in this package's own tests (and at least one production path this class does not control -- a direct LogStreamManager.getInstance().initialize(...) call bypassing wireManagers() entirely) install log-stream wiring WITHOUT ever routing through this class, so this field can legitimately be null even while a handler is, in fact, attached. shutdownLogStreamManager() therefore only ever refuses to shut down when this field names ANOTHER, specific, still-current session -- never merely because it is unset -- so every pre-existing "logout detaches the handler" guarantee this class already made before issue #465 continues to hold exactly as before for every caller that never populates this field.

  • Constructor Details

    • CloudSession

      CloudSession()
  • Method Details

    • current

      static CloudSession current()
      The session every CloudAuthManager/PluginInitiationUtils static facade method currently delegates to.
      Returns:
      the current session, never null
    • startNew

      static CloudSession startNew()
      Constructs a new session, installs it as current(), and invalidates whatever session was current before -- the single operation both login and logout perform (D-16). Invalidating the previous session happens after it has been replaced as current(), but that ordering is not itself load-bearing: invalidate()'s own gate is this specific instance's invalidated flag, not a comparison against current(), so a caller already holding a reference to the old session sees it become invalid regardless of when the swap becomes visible to a third reader.
      Returns:
      the new, now-current session
    • resetForTesting

      static void resetForTesting()
      Test-only: replaces current() with a brand-new, non-invalidated session, without invalidating whatever was current before. Production code has no equivalent -- every real replacement goes through startNew(), which does invalidate the session it replaces. Exists only because this class's static current field is JVM-wide and this module's Surefire configuration runs with no forkCount (issue #250), so a test suite that leaves a stale, already-invalidated session installed as current would leak that state into every test class that runs afterward in the same JVM.
    • commit

      boolean commit(TokenEntity newToken) throws IOException
      Persists token through this session's own TokenStore, but only if this session has not been invalidated. This is the only path that writes a token to disk (D-18) -- there is no static, session-independent save.
      Parameters:
      newToken - the token to persist
      Returns:
      true if committed; false if this session was already invalidated, in which case nothing was written
      Throws:
      IOException - if the underlying write fails
    • invalidate

      void invalidate()
      Marks this session invalid and tears down everything it owns: the log stream manager (as of plan 16-10, WR-01), the poll and refresh schedulers, and (as of plan 16-08 Task 2) the WebSocket client. Safe to call on a session that never started any of those (a freshly constructed session with nothing scheduled and no client yet) and safe to call more than once -- both are exercised directly by this class's own tests, independently of the static current() holder.

      WR-01 (16-REVIEW-cloud.md): the log-stream-manager shutdown used to run in PluginInitiationUtils.doDisableCloud(), before this method and with no lock at all. A late onWebSocketOpened landing in that unlocked gap would observe this session as still current -- because it genuinely still was, at that exact instant -- and re-attach the log handler that step had just detached; invalidate() would then run and close the WebSocket client, but never re-run the log-stream shutdown a second time. Moving the shutdown inside this method closes that gap: initializeManagers() (which re-wires the log stream manager) and this method both synchronize on this, so one of them always runs to completion before the other can even enter -- there is no window in which "wiring" can see a stale "still current" read while "teardown" is genuinely underway. The original ordering concern this step's comment used to document -- log flush before the WebSocket client closes, so sendBatch() does not find the socket already down with a non-empty queue -- is preserved: this step still runs before closeWebSocketClient() below, just now under the same lock as everything else.

      Real-machine UAT fix, plan 16-08: pendingAuthenticationCancelled is captured here -- reading pollTask BEFORE stopPolling() below clears it -- so hasAnythingToClear() can report that this call genuinely cancelled a real, in-flight authentication attempt even though nothing was ever committed to token. See that field's own javadoc for the full account.

    • shutdownLogStreamManager

      private void shutdownLogStreamManager()
      Shuts down the framework's log stream manager, if one is configured. Extracted so invalidate() reads as a flat list of teardown steps, and tolerant of a null manager (unmocked in the majority of this package's tests) exactly like closeWebSocketClient() already tolerates a null client -- neither condition is an error, both are simply nothing to tear down.

      16-10 gap-closure addendum, issue #465: before this fix, this step ran unconditionally whenever ANY session invalidated -- including a stale, already-superseded session's own redundant second invalidation (e.g. a delayed reconnect-exhaustion callback for an old session arriving after a replacement session had already completed its own handshake and wired its own log handler). Because LogStreamManager is a JVM-wide singleton, that unconditional shutdown would detach the REPLACEMENT session's own, currently-valid wiring -- with no later handshake ever coming to re-attach it, since the replacement session's own onOpen had already run. Gating on logStreamOwner (held under LOG_WIRING_LOCK, the innermost lock in this class's documented order) closes that: this session's own teardown only ever shuts the manager down if it is the one that most recently wired it (or nothing has ever recorded ownership at all -- see logStreamOwner's own javadoc for why an unset owner must still shut down, not skip). A session that finds a DIFFERENT, specific owner recorded leaves the manager alone and does NOT clear the record -- the owning session's own eventual teardown is what does that.

    • closeWebSocketClient

      void closeWebSocketClient()
      Disconnects and clears this session's WebSocket client, if any. Extracted out of invalidate() so the client-close step is a named, independently callable action -- exactly the shape issue #298's fourth acceptance criterion (teardown order is not a correctness precondition) needs to test each of the four named steps in isolation, in whatever order a test chooses. Production code only ever reaches this through invalidate().
    • markInvalidatedForTesting

      void markInvalidatedForTesting()
      Test-only: marks this session invalidated without touching the poller, the refresher, or the WebSocket client -- the flag-setting half of invalidate(), isolated so a test can establish "logout has been decided" as a precondition and then permute the four mechanical teardown actions (stopPolling(), stopTokenRefreshScheduler(), closeWebSocketClient(), clearPersisted()) in any order afterward, proving their relative order does not change the outcome (D-16/D-18, issue #298's fourth acceptance criterion). Production code never calls this in isolation; invalidate() always sets the same flag together with the poller/refresher/client cleanup, in one atomic call.
    • isCurrent

      boolean isCurrent()
      Returns:
      true if this session has not been invalidated
    • getToken

      TokenEntity getToken()
      Returns:
      the token this session currently holds, or null
    • hasAnythingToClear

      boolean hasAnythingToClear()
      Returns:
      true if this session's teardown genuinely cancelled or is responsible for clearing something -- this session's own token, (round-13 review, P1) the nearest ancestor's predecessorToken, or (real-machine UAT fix, plan 16-08) a magic-link poll that was still in flight and just got cancelled by invalidate() (see pendingAuthenticationCancelled's own javadoc). false only if all three are absent, meaning this teardown genuinely found nothing pending -- neither a credential on disk nor an authentication attempt in progress. CloudAuthManager#logout() gates its "nothing to clear" early return on this, not on getToken() alone, so a session that never itself held a token but replaced one that did (exhaustion followed by /ulticloud login), or one that cancelled a poll before it ever committed anything, still reports a genuine logout occurred.
    • getWebSocketClient

      UltiPanelWebSocketClient getWebSocketClient()
      Returns:
      this session's WebSocket client, or null if none is connected
    • setWebSocketClient

      void setWebSocketClient(UltiPanelWebSocketClient client)
      Installs client as this session's WebSocket client, replacing (without closing) any previous one. Callers that need the previous client closed first should read getWebSocketClient(), close it themselves, and only then call this.
      Parameters:
      client - the client to install, or null to clear the reference without closing it
    • getBackoff

      Returns:
      this session's own reconnect backoff strategy -- never shared with any other session
    • clearPersisted

      void clearPersisted() throws IOException
      Removes the persisted credential from disk, but ONLY the copy this specific session itself is responsible for, and clears this session's in-memory token unconditionally (a purely local field on an instance nothing else can observe).

      Called by CloudAuthManager.logout() (plan 16-10) on the session PluginInitiationUtils.disableCloud(CloudSession) actually tore down.

      Round-1 external review finding (16-10, PR #464): credentials.json is one shared document across every session that has ever existed. A blind, unconditional disk clear here -- the original 16-09 shape -- could remove a credential a NEWER session committed while this (older, being-logged-out) session's teardown was still in flight: the new session passes its own currency check, its commit succeeds and writes to the shared document, and this method's own unconditional clear would then wipe that fresh write, even though the operator's re-login had already genuinely succeeded. Delegating to TokenStore.clearIfMatches(TokenEntity) with this session's own token turns the disk clear into a compare-and-delete: it only ever removes the credential THIS session itself last knows to be persisted, never a value some other session wrote afterward.

      Round-5 external review finding (16-10, PR #464): if the disk-side clear fails -- credentials.json is unwritable or the underlying file is corrupt -- the old shape returned before ever reaching the in-memory clear, so token stayed set to the value this session (already invalidated by the caller's logout()) still believed was valid. At the time of this finding, hasValidToken() did not consult invalidated at all (see round-10's own fix on that method for the separate, independent gap this exposed), so every subsequent /ulticloud login kept reporting "Already logged in" against a session nothing could ever make current again, until the server restarted. The disk write and the in-memory clear are two independent effects with no ordering relationship the caller can rely on to fix this the other way around, so the in-memory half is moved into a finally block: it now always runs, whether the disk write succeeds, fails with a propagated exception, or is never reached at all. This remains correct and necessary on its own even after round-10's fix -- this method is package-private and callable directly (as this class's own tests do) on a session that has not itself been invalidated yet, a case round-10's invalidated check does not cover.

      Round-13 external review finding (16-10 gap-closure addendum, PR #464), P1: if THIS session never itself held a token (the common shape after /ulticloud login replaces an exhausted-but-never-logged-out predecessor -- see predecessorToken's own javadoc), comparing against token alone compares against null, which TokenStore.clearIfMatches(TokenEntity) always treats as "no match" -- so the predecessor's still-persisted, still-valid credential was never reachable by this session's own logout, surviving on disk despite the operator's explicit intent. Falling back to predecessorToken widens the compare-and-delete's target WITHOUT reopening the round-1 race that method's own javadoc documents: a concurrent, genuinely newer commit still writes a different access token value, so the comparison still correctly fails against it either way.

      Real-machine UAT fix, plan 16-08: also resets pendingAuthenticationCancelled back to false in the same finally block, for the same reason token and predecessorToken are reset here -- a second, independent logout() call on this same (already-invalidated) session must not keep reporting success for a poll this session already reported cancelling once.

      Throws:
      IOException - if the underlying write fails
    • hasValidToken

      boolean hasValidToken()
      Returns:
      true if this session has not been invalidated AND holds a non-expired token with an access token
    • loadFromDisk

      TokenEntity loadFromDisk()
      Loads a previously-persisted credential from disk into this session. If the access token is expired but a refresh token exists, attempts a refresh (through this same session, so the refreshed result is subject to the same commit(TokenEntity) guard as every other asynchronous credential operation).
      Returns:
      the loaded (or refreshed) token, or null
    • refresh

      TokenEntity refresh(String refreshTokenValue)
      Refreshes the access token using refreshTokenValue, committing the result through this session. Calls POST /oauth/token with grant_type=refresh_token.

      A caller obtains this before making the call -- typically current() at the moment the refresh was decided -- so whatever session this instance is, that is the session the result is checked against, no matter how much time this blocking HTTP call takes or how many logouts happen while it is in flight.

      Parameters:
      refreshTokenValue - the refresh token string
      Returns:
      the refreshed token if the HTTP call succeeded AND this session was still current when the result came back; null otherwise
    • requestMagicLink

      String requestMagicLink(Consumer<String> errorCallback)
      Requests a magic link for server authentication and, on success, starts polling for its completion on this session's own executor.
      Parameters:
      errorCallback - called with an error message if the request fails
      Returns:
      the magic link URL, or null on failure
    • startPolling

      void startPolling(String requestId, Consumer<TokenEntity> onComplete)
      Starts polling for magic-link auth completion on this session's own executor. Stopping this session's polling (via invalidate(), or a direct stopPolling()) is enough to make every subsequent tick a no-op -- no separate credential-generation comparison is needed, because pollLoginStatusOnce(java.lang.String, java.util.function.Consumer<com.ultikits.ultitools.entities.TokenEntity>) below runs as a method on this instance.

      Round-1 review, fourth pass (16-10, PR #464): this method is called from requestMagicLink(Consumer), which itself runs after a blocking HTTP POST -- a multi-second-to-longer round trip during which a /ulticloud logout can invalidate this session entirely. Before this fix, this method had no gate of its own: it unconditionally created a fresh poller even on an already-invalidated session, one nothing will ever stop again (stopCredentialSchedulers() only ever acts on current(), which by then points elsewhere), leaking a status-polling task running every three seconds for up to five minutes. Checking invalidated here, inside the same monitor invalidate() itself synchronizes on, closes that: either this method runs to completion before invalidate() can (and its poller is torn down normally, the ordinary case), or invalidate() has already run and this call sees invalidated and refuses to schedule anything at all.

      Parameters:
      requestId - the magic link request ID
      onComplete - called when auth succeeds (with the token), or null
    • pollLoginStatusOnce

      private void pollLoginStatusOnce(String requestId, Consumer<TokenEntity> onComplete)
      Check login status once. Any exception is logged at FINE only -- polling must continue until it times out or reaches a terminal state.
    • completeMagicLinkLogin

      private void completeMagicLinkLogin(com.google.gson.JsonObject responseBody, Consumer<TokenEntity> onComplete) throws IOException
      Handles a login that just reached completed: persists the credential through this session, then reactivates cloud features -- both steps guard against a logout landing in the middle by checking this same this session, so the two can never disagree about whether the login is still current.
      Throws:
      IOException - if persisting the credential fails
    • stopPolling

      void stopPolling()
      Stop polling for magic-link completion on this session.
    • startTokenRefreshScheduler

      void startTokenRefreshScheduler()
      Starts a background scheduler on this session that proactively refreshes the access token before it expires (checks every hour, refreshes when <2 hours remaining).
    • stopTokenRefreshScheduler

      void stopTokenRefreshScheduler()
      Stop the background token refresh scheduler on this session.
    • initializeManagers

      void initializeManagers()
      Wires all WebSocket managers up to this session's connection, but only if this session is still current.

      This method hangs off onConnectHandler (via PluginInitiationUtils.onWebSocketOpened → PluginInitiationUtils.initializeManagers() → here), and an in-flight handshake can still land after /ulticloud logout. Without a guard, the listeners disableCloud() just tore down would be reinstalled verbatim by this late-arriving onOpen — the exact same "no one owns the decision" defect from #181/#223, resurfacing in a different place.

      Checking isCurrent() alone is not enough. That would be only a read taken outside a lock: after it reads true but before this method actually wires anything up, invalidate() could cut in on another thread, tear everything down cleanly, and then this method would continue on and wire the listeners right back up. So both this method and invalidate() synchronize on this (this session's own monitor, replacing the former global lifecycle lock — see the two review rounds on PR #264 for the original race), which is why the currency check below is safe to trust once taken: teardown either has not started yet or has already run to completion by the time this returns from the check, never caught in the middle.

    • wireManagers

      private void wireManagers()
      The actual wiring performed by initializeManagers(). Callers must hold this session's own monitor (i.e. call only from a synchronized(this) context).

      D-11/D-12: the four outbound capabilities (monitoring/logs/ player-events/server-properties) decide here, via Capability.isEnabled(), whether to start collecting data at all — not whether to discard it at the send-side after collection. The latter would still leave data already gathered into memory, just never transmitted, and D-12 explicitly rejects that "exposed but not transmitted" shape. Every client-reference wiring call is deliberately kept unconditional: assigning a client reference by itself starts no collection, and running it unconditionally is what guarantees every manager getter is always non-null and every manager always exists (D-11) — the dispatch table has two manager-getter dereferences with no null check.