Class CloudSession
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.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.stopTokenRefreshScheduler()andstopPolling()both usecancel(false)plusshutdown(), 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.
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:
CloudAuthManager.class-- held for the duration ofCloudAuthManager.logout()only; never nested with any of the three locks below at the same time in the current code (its own call intoinvalidate()and into thesynchronized (CloudSession.class)block insidePluginInitiationUtils#doDisableCloudrun sequentially, not nested), but documented here so a future change that DOES nest it keeps this same relative order rather than inventing one.CloudSession.class-- held bystartNew()and byCloudAuthManager.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.- a specific session's own monitor (
synchronized (this)/synchronized(session)) -- held bycommit(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 inPluginInitiationUtilsthat needs to read-then-act on a session's currency (reinitWebSocket's second confirmation,activateCloudIfCurrent,doDisableCloud's two global-manager steps). LOG_WIRING_LOCK(16-10 gap-closure addendum, issue #465) -- the innermost lock, held only insideshutdownLogStreamManager()and insidewireManagers()'s ownLOGSbranch, both of which are themselves only ever reached from within asynchronized (this)region (invalidate()andinitializeManagers()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 Summary
FieldsModifier and TypeFieldDescriptionprivate final ExponentialBackoffStrategyThe global budget and backoff for this session's outer reconnection (reinit loop).private static CloudSessionThe current session.private static final com.google.gson.Gsonprivate booleanprivate static final ObjectThe innermost lock in this class's documented lock order (see the class javadoc) -- guardslogStreamOwneronly.private static CloudSessionWhich session's log-stream wiring is currently installed on the JVM-wideLogStreamManagersingleton, ornullif no session has ever wired it (or the last session to do so has already cleanly detached it).private static final int(package private) static final intThe global cap on outer reinit attempts.private static final StringBasic auth header for OAuth2 client credentials (client:112233)private booleantrueifinvalidate()found -- and cancelled -- a magic-link poll still in flight on this session, and that fact has not yet been reported and reset byclearPersisted().private static final longprivate ScheduledExecutorServiceprivate ScheduledFuture<?> private TokenEntityThe nearest ancestor session's last-known token, ornullif either there is no ancestor (this is the first session ever created) or that ancestor never itself held a token.private ScheduledExecutorServiceprivate ScheduledFuture<?> private TokenEntityprivate static final longHow often to check if the access token needs refreshing (1 hour)private static final longRefresh the token when it has less than this many seconds remaining (2 hours)private final TokenStoreprivate UltiPanelWebSocketClientThis session's own WebSocket client, ornullif none is connected. -
Constructor Summary
Constructors -
Method Summary
Modifier and TypeMethodDescription(package private) voidRemoves 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).(package private) voidDisconnects and clears this session's WebSocket client, if any.(package private) booleancommit(TokenEntity newToken) Persiststokenthrough this session's ownTokenStore, but only if this session has not been invalidated.private voidcompleteMagicLinkLogin(com.google.gson.JsonObject responseBody, Consumer<TokenEntity> onComplete) Handles a login that just reachedcompleted: persists the credential through this session, then reactivates cloud features -- both steps guard against a logout landing in the middle by checking this samethissession, so the two can never disagree about whether the login is still current.(package private) static CloudSessioncurrent()The session everyCloudAuthManager/PluginInitiationUtilsstatic facade method currently delegates to.(package private) ExponentialBackoffStrategy(package private) TokenEntitygetToken()(package private) UltiPanelWebSocketClient(package private) boolean(package private) boolean(package private) voidWires all WebSocket managers up to this session's connection, but only if this session is still current.(package private) voidMarks 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.(package private) boolean(package private) TokenEntityLoads a previously-persisted credential from disk into this session.(package private) voidTest-only: marks this session invalidated without touching the poller, the refresher, or the WebSocket client -- the flag-setting half ofinvalidate(), 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).private voidpollLoginStatusOnce(String requestId, Consumer<TokenEntity> onComplete) Check login status once.(package private) TokenEntityRefreshes the access token usingrefreshTokenValue, committing the result through this session.(package private) StringrequestMagicLink(Consumer<String> errorCallback) Requests a magic link for server authentication and, on success, starts polling for its completion on this session's own executor.(package private) static voidTest-only: replacescurrent()with a brand-new, non-invalidated session, without invalidating whatever was current before.(package private) voidInstallsclientas this session's WebSocket client, replacing (without closing) any previous one.private voidShuts down the framework's log stream manager, if one is configured.(package private) static CloudSessionstartNew()Constructs a new session, installs it ascurrent(), and invalidates whatever session was current before -- the single operation bothloginandlogoutperform (D-16).(package private) voidstartPolling(String requestId, Consumer<TokenEntity> onComplete) Starts polling for magic-link auth completion on this session's own executor.(package private) voidStarts a background scheduler on this session that proactively refreshes the access token before it expires (checks every hour, refreshes when <2 hours remaining).(package private) voidStop polling for magic-link completion on this session.(package private) voidStop the background token refresh scheduler on this session.private voidThe actual wiring performed byinitializeManagers().
-
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_MSHow often to check if the access token needs refreshing (1 hour)- See Also:
-
TOKEN_REFRESH_THRESHOLD_SECONDS
private static final long TOKEN_REFRESH_THRESHOLD_SECONDSRefresh the token when it has less than this many seconds remaining (2 hours)- See Also:
-
OAUTH2_BASIC_AUTH
Basic auth header for OAuth2 client credentials (client:112233)- See Also:
-
MAX_REINIT_ATTEMPTS
static final int MAX_REINIT_ATTEMPTSThe global cap on outer reinit attempts. Once exceeded, the state machine enters a terminal state and only/ulticloud loginor a restart recovers it. Package-private (not private) soPluginInitiationUtils.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
The current session. Nevernull-- initialised eagerly so every static facade method onCloudAuthManageralways has something to delegate to, even before any login has ever happened. -
tokenStore
-
token
-
invalidated
private volatile boolean invalidated -
predecessorToken
The nearest ancestor session's last-known token, ornullif either there is no ancestor (this is the first session ever created) or that ancestor never itself held a token. Set only bystartNew(), consulted only byclearPersisted()as a fallback when THIS session's owntokenisnull-- deliberately never consulted byhasValidToken()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 owntokenunset-but-still-in-memory -- clearing the credential is logout's job, not exhaustion's, perhasValidToken()'s own round-10 note) that is then replaced by/ulticloud loginviastartNew()left its still-valid, still-persisted credential unreachable by any LATER/ulticloud logoutcall: the new (blank) session has no token of its own, soTokenStore.clearIfMatches(TokenEntity)'s compare-and-delete had nothing to compare against, andlogout()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-nullfield), so the credential stays reachable no matter how many replacements happen before a logout finally runs. -
pendingAuthenticationCancelled
private volatile boolean pendingAuthenticationCancelledtrueifinvalidate()found -- and cancelled -- a magic-link poll still in flight on this session, and that fact has not yet been reported and reset byclearPersisted().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 reachedcommit(TokenEntity)and therefore never settokenorpredecessorToken-- both of whichhasAnythingToClear()used to consult exclusively. Without this field,/ulticloud logoutrun 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 owninvalidatedcheck already rejects it regardless of this field. Only the reported OUTCOME of the logout that cancelled the poll was wrong.Captured in
invalidate(), immediately beforestopPolling()clearspollTask-- readingpollTaskany later would always seenulland this field could never becometrue. Reset back tofalseinclearPersisted()'s ownfinallyblock, alongsidetokenandpredecessorToken, for the same reason those two are reset there: a second, independentlogout()call on this same (already-invalidated) session instance -- e.g. a second/ulticloud logoutbefore any new login replaces it -- must not keep reporting success for a poll this session already reported cancelling once. -
pollExecutor
-
pollTask
-
refreshExecutor
-
refreshTask
-
webSocketClient
This session's own WebSocket client, ornullif none is connected. Task 2 of plan 16-08 moved this offPluginInitiationUtils's staticpanelWSfield -- 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
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.reinitWebSocketbuilds 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 successfulonOpenresets 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
The innermost lock in this class's documented lock order (see the class javadoc) -- guardslogStreamOwneronly. A dedicated lock object, notthis/a session monitor and notCloudSession.class: the ownership check it protects must be readable fromshutdownLogStreamManager()(called frominvalidate(), under the tearing-down session's own monitor) while comparing against whichever session's monitorwireManagers()(called frominitializeManagers(), 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
Which session's log-stream wiring is currently installed on the JVM-wideLogStreamManagersingleton, ornullif no session has ever wired it (or the last session to do so has already cleanly detached it). Guarded exclusively byLOG_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 directLogStreamManager.getInstance().initialize(...)call bypassingwireManagers()entirely) install log-stream wiring WITHOUT ever routing through this class, so this field can legitimately benulleven 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
The session everyCloudAuthManager/PluginInitiationUtilsstatic facade method currently delegates to.- Returns:
- the current session, never
null
-
startNew
Constructs a new session, installs it ascurrent(), and invalidates whatever session was current before -- the single operation bothloginandlogoutperform (D-16). Invalidating the previous session happens after it has been replaced ascurrent(), but that ordering is not itself load-bearing:invalidate()'s own gate is this specific instance'sinvalidatedflag, not a comparison againstcurrent(), 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: replacescurrent()with a brand-new, non-invalidated session, without invalidating whatever was current before. Production code has no equivalent -- every real replacement goes throughstartNew(), which does invalidate the session it replaces. Exists only because this class's staticcurrentfield is JVM-wide and this module's Surefire configuration runs with noforkCount(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
Persiststokenthrough this session's ownTokenStore, 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:
trueif committed;falseif 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 staticcurrent()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 lateonWebSocketOpenedlanding 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 onthis, 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, sosendBatch()does not find the socket already down with a non-empty queue -- is preserved: this step still runs beforecloseWebSocketClient()below, just now under the same lock as everything else.Real-machine UAT fix, plan 16-08:
pendingAuthenticationCancelledis captured here -- readingpollTaskBEFOREstopPolling()below clears it -- sohasAnythingToClear()can report that this call genuinely cancelled a real, in-flight authentication attempt even though nothing was ever committed totoken. 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 soinvalidate()reads as a flat list of teardown steps, and tolerant of anullmanager (unmocked in the majority of this package's tests) exactly likecloseWebSocketClient()already tolerates anullclient -- 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
LogStreamManageris 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 ownonOpenhad already run. Gating onlogStreamOwner(held underLOG_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 -- seelogStreamOwner'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 ofinvalidate()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 throughinvalidate(). -
markInvalidatedForTesting
void markInvalidatedForTesting()Test-only: marks this session invalidated without touching the poller, the refresher, or the WebSocket client -- the flag-setting half ofinvalidate(), 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:
trueif this session has not been invalidated
-
getToken
TokenEntity getToken()- Returns:
- the token this session currently holds, or
null
-
hasAnythingToClear
boolean hasAnythingToClear()- Returns:
trueif this session's teardown genuinely cancelled or is responsible for clearing something -- this session's owntoken, (round-13 review, P1) the nearest ancestor'spredecessorToken, or (real-machine UAT fix, plan 16-08) a magic-link poll that was still in flight and just got cancelled byinvalidate()(seependingAuthenticationCancelled's own javadoc).falseonly 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 ongetToken()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
nullif none is connected
-
setWebSocketClient
Installsclientas this session's WebSocket client, replacing (without closing) any previous one. Callers that need the previous client closed first should readgetWebSocketClient(), close it themselves, and only then call this.- Parameters:
client- the client to install, ornullto clear the reference without closing it
-
getBackoff
ExponentialBackoffStrategy getBackoff()- Returns:
- this session's own reconnect backoff strategy -- never shared with any other session
-
clearPersisted
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 sessionPluginInitiationUtils.disableCloud(CloudSession)actually tore down.Round-1 external review finding (16-10, PR #464):
credentials.jsonis 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 toTokenStore.clearIfMatches(TokenEntity)with this session's owntokenturns 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.jsonis unwritable or the underlying file is corrupt -- the old shape returned before ever reaching the in-memory clear, sotokenstayed set to the value this session (already invalidated by the caller'slogout()) still believed was valid. At the time of this finding,hasValidToken()did not consultinvalidatedat all (see round-10's own fix on that method for the separate, independent gap this exposed), so every subsequent/ulticloud loginkept 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 afinallyblock: 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'sinvalidatedcheck 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 loginreplaces an exhausted-but-never-logged-out predecessor -- seepredecessorToken's own javadoc), comparing againsttokenalone compares againstnull, whichTokenStore.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 topredecessorTokenwidens 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
pendingAuthenticationCancelledback tofalsein the samefinallyblock, for the same reasontokenandpredecessorTokenare reset here -- a second, independentlogout()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:
trueif 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 samecommit(TokenEntity)guard as every other asynchronous credential operation).- Returns:
- the loaded (or refreshed) token, or
null
-
refresh
Refreshes the access token usingrefreshTokenValue, committing the result through this session. CallsPOST /oauth/tokenwithgrant_type=refresh_token.A caller obtains
thisbefore making the call -- typicallycurrent()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;
nullotherwise
-
requestMagicLink
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
nullon failure
-
startPolling
Starts polling for magic-link auth completion on this session's own executor. Stopping this session's polling (viainvalidate(), or a directstopPolling()) is enough to make every subsequent tick a no-op -- no separate credential-generation comparison is needed, becausepollLoginStatusOnce(java.lang.String, java.util.function.Consumer<com.ultikits.ultitools.entities.TokenEntity>)below runs as a method onthisinstance.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 logoutcan 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 oncurrent(), which by then points elsewhere), leaking a status-polling task running every three seconds for up to five minutes. Checkinginvalidatedhere, inside the same monitorinvalidate()itself synchronizes on, closes that: either this method runs to completion beforeinvalidate()can (and its poller is torn down normally, the ordinary case), orinvalidate()has already run and this call seesinvalidatedand refuses to schedule anything at all.- Parameters:
requestId- the magic link request IDonComplete- called when auth succeeds (with the token), ornull
-
pollLoginStatusOnce
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 reachedcompleted: persists the credential through this session, then reactivates cloud features -- both steps guard against a logout landing in the middle by checking this samethissession, 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(viaPluginInitiationUtils.onWebSocketOpened→PluginInitiationUtils.initializeManagers()→ here), and an in-flight handshake can still land after/ulticloud logout. Without a guard, the listenersdisableCloud()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 andinvalidate()synchronize onthis(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 byinitializeManagers(). Callers must hold this session's own monitor (i.e. call only from asynchronized(this)context).D-11/D-12: the four outbound capabilities (
monitoring/logs/player-events/server-properties) decide here, viaCapability.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.
-