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 protocol v2 server credential, the device-login poll it started and its agent connection, which keeps its own reconnect backoff (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, a reconnect attempt -- lapses on its own once invalidate() has run, because commitCredential(ServerCredential) and every gate this class exposes check this specific instance's invalidated flag, never a shared counter another session's activity could also advance. (The 6.3.0 user token, its store and its refresh scheduler were removed in 6.3.1, plan 22-24: protocol v2 never sends it.)

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). The locks 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(), CloudAuthManager.logoutReporting(java.util.function.Consumer) and CloudAuthManager.reconnect() (all three are synchronized methods). They DO nest it with the locks below, in this list's order: logout's and logoutReporting's own synchronized (CloudSession.class) block calls invalidate() on the session monitor inside it, and reconnect takes the session monitor inside CloudSession#reconnectNow(). No path in this package acquires them the other way round.
  2. CloudSession.class -- held by startNew() and by CloudAuthManager.login(com.ultikits.ultitools.utils.CloudAuthManager.DeviceLoginListener) (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 invalidate(), stopPolling(), (protocol v2, plan 22-01) commitCredential(ServerCredential), beginDeviceLogin(com.ultikits.ultitools.utils.CloudAuthManager.DeviceLoginListener), startDevicePolling(com.ultikits.ultitools.utils.DeviceAuthorizationClient.Grant, com.ultikits.ultitools.utils.CloudAuthManager.DeviceLoginListener) and the device-step scheduling they share, and by every caller in PluginInitiationUtils that needs to read-then-act on a session's currency (reinitWebSocket's second confirmation, doDisableCloud's two global-manager steps).
  4. CloudStateStore's JVM-wide write lock (plan 22-07) -- taken by every load, flush and detach of cloud-outbox.json, always below the session monitor: invalidate()'s and the shutdown flush's write cannot interleave with the 30 s task's, which holds only this lock and never the session monitor. Its inner locks are the CloudOutbox and CommandDedupeSet monitors, which are leaves.
  5. The AgentConnection monitor and CredentialStore.LOCK, taken by ServerCredentialStore, are siblings: never nested in one another, always below the session monitor. AgentConnection never calls out while holding its monitor. Its scheduler callbacks take the session monitor first and reject invalidated or replaced sessions and replaced connections before acting.
  6. (plan 22-20) The private lock of the connection's AgentProtocol -- below the session monitor (taken inside it by offerReliableEvent(java.lang.String, com.google.gson.JsonObject) and stopConnection()) and above the AgentConnection monitor and the CloudOutbox and CommandDedupeSet leaf monitors, which it takes while handing a frame to the socket or applying the welcome's limits. It is never held while calling a listener or this session, so the inbound path -- which reads the current protocol under the session monitor, then releases it before AgentProtocol.onText(java.lang.String) -- cannot invert it.
Teardown under any of these locks uses only non-blocking AgentConnection.stop() and executor shutdown(). It never waits for the agent thread (no awaitTermination, Future.get, join, connectBlocking or closeBlocking): that thread may itself be waiting for the session monitor. 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. commitCredential(ServerCredential) writes through ServerCredentialStore, so CredentialStore's own lock is taken inside the session monitor -- never the other way round.

Protocol v2 device login (plan 22-01). A session also owns one RFC 8628 device authorization: beginDeviceLogin(com.ultikits.ultitools.utils.CloudAuthManager.DeviceLoginListener) requests the device code and startDevicePolling(com.ultikits.ultitools.utils.DeviceAuthorizationClient.Grant, com.ultikits.ultitools.utils.CloudAuthManager.DeviceLoginListener) polls for the owner's confirmation, both as steps on this session's own poll executor, so a replaced or logged-out session's late steps lapse by the same invalidated check commitCredential(ServerCredential) uses. The granted ucs_ credential is persisted only through commitCredential(ServerCredential).

Since:
6.3.0
  • Field Details

    • STATE_FLUSH_INTERVAL_MS

      private static final long STATE_FLUSH_INTERVAL_MS
      How often the session persists its reliable-delivery state (D-15): the outbox and the command-dedupe set are written to .ultikits/cloud-outbox.json only when one of them changed since the last write, but the change itself is observed on this cadence.
      See Also:
    • SERVER_ID_MISMATCH_KEY

      static final String SERVER_ID_MISMATCH_KEY
      The language-catalogue key of the one SEVERE line printed when the gateway's welcome names a different server id than the linked one (contract answer 21, SEMANTICS 16).
      See Also:
    • DEVICE_SLOW_DOWN_INCREMENT_MS

      private static final long DEVICE_SLOW_DOWN_INCREMENT_MS
      Added to the device poll interval for each slow_down answer (RFC 8628 section 3.5).
      See Also:
    • deviceClientOverride

      private static volatile DeviceAuthorizationClient deviceClientOverride
      Test seam: replaces the device-login REST client of every session; null = default.
    • deviceSchedulerOverride

      private static volatile CloudSession.DeviceStepScheduler deviceSchedulerOverride
      Test seam: replaces the device-step scheduler of every session; null = poll executor.
    • deviceClockOverride

      private static volatile LongSupplier deviceClockOverride
      Test seam: replaces the device-login clock of every session; null = wall clock.
    • 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.
    • invalidated

      private volatile boolean invalidated
    • pendingAuthenticationCancelled

      private volatile boolean pendingAuthenticationCancelled
      true if invalidate() found -- and cancelled -- a pending authentication still in flight on this session (as of 6.3.1 the device-code request or poll; a magic-link poll in 6.3.0), and that fact has not yet been reported and reset by consumePendingCancellation().

      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 committed a credential. Without this field, /ulticloud logout run while a login's poll is still running 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: commitCredential(ServerCredential)'s own invalidated check 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 by consumePendingCancellation(), so a second, independent logout() call on this same (already-invalidated) session instance -- e.g. a second /ulticloud logout before any new login replaces it -- does not keep reporting success for a poll this session already reported cancelling once.

    • credential

      private volatile ServerCredential credential
      The protocol v2 server credential this session holds, or null -- the one owner of the current server credential (plan 22-21 fix). Every change of the stored cloud_credential entry is made under this session's monitor together with this field: loadServerCredential() (adopt from disk), commitCredential(ServerCredential) (device login), rotateCredential(String, String) (credential.rotate, disk first), completeCredentialLogout(ServerCredential) and the listener's rejection path (delete-if-matches, then cleared). Every reader in the session -- the bearer of each attempt, the protocol's server id, the linked checks -- and CloudAuthManager's status, login and logout read this field; they read the file only while the session holds no credential.
    • cloudOutbox

      private volatile CloudOutbox cloudOutbox
      The bounded buffer of unacknowledged reliable events of this session's credential, or null while no credential is adopted (or after the identity was discarded). Written only while holding this session's monitor; volatile because the 30 s flush task on the agent thread reads it without taking that monitor.
    • commandDedupe

      private volatile CommandDedupeSet commandDedupe
      The recently seen command ids of this session's credential, or null.
    • cloudState

      private volatile CloudStateStore cloudState
      The state file of this session's credential, or null.
    • cloudStateServerId

      private volatile String cloudStateServerId
      The server id whose buffered state the three fields above hold, or null.
    • ledgerTarget

      private volatile SessionDedupeLedger.Target ledgerTarget
      The id set, state file and server id above as one reference (plan 22-21), so the shared SessionDedupeLedger never sees one session's set with another identity's file. Set and cleared with them, under this session's monitor; read without it.
    • stateFlushTask

      private volatile ScheduledFuture<?> stateFlushTask
      The pending 30 s state-flush task of this session's connection, or null.
    • stateFlushFailureLogged

      private volatile boolean stateFlushFailureLogged
      Whether the current state-write failure streak already logged its one WARNING (T-22-31).
    • agentConnection

      private AgentConnection agentConnection
    • agentListener

      private CloudSession.SessionAgentListener agentListener
      The listener of agentConnection, so a rotation can re-point the credential its 4401 deletes.
    • agentProtocol

      private volatile AgentProtocol agentProtocol
      The protocol layer of the current connection's open attempt (plan 22-20), or null between attempts. One per upgraded socket: created when the upgrade is accepted, closed when the connection leaves OPEN. Written under this session's monitor.
    • agentExecutor

      private ScheduledExecutorService agentExecutor
    • agentClock

      private LongSupplier agentClock
    • agentEndpointOverride

      private static volatile URI agentEndpointOverride
    • agentPolicyOverride

      private static volatile ReconnectPolicy agentPolicyOverride
    • agentTimingOverride

      private static volatile AgentConnection.Timing agentTimingOverride
    • agentExecutorOverride

      private static volatile Supplier<ScheduledExecutorService> agentExecutorOverride
    • agentSocketsOverride

      private static volatile AgentConnection.SocketFactory agentSocketsOverride
    • agentClockOverride

      private static volatile LongSupplier agentClockOverride
    • deviceClient

      private final DeviceAuthorizationClient deviceClient
      The REST client of the device authorization grant.
    • pollExecutor

      private ScheduledExecutorService pollExecutor
    • pollTask

      private ScheduledFuture<?> pollTask
  • 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.
    • 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 device-login poll, the pending reliable-delivery state (D-15) and the agent connection. Safe to call on a session that never started any of those and safe to call more than once -- both are exercised directly by this class's own tests, independently of the static current() holder.

      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 no credential was ever committed. 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): no manager is simply nothing to tear down. As of 6.3.1 no framework path wires the log stream manager (its protocol v1 wiring was removed, plan 22-24); the shutdown still runs so a module that wired it through the kept public API is detached on logout as in 6.3.0.
    • markInvalidatedForTesting

      void markInvalidatedForTesting()
      Test-only: marks this session invalidated without touching the poller or the connection -- the flag-setting half of invalidate(), isolated so a test can establish "logout has been decided" as a precondition and then permute the mechanical teardown actions (stopPolling(), stopConnection()) 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 cleanup, in one atomic call.
    • isCurrent

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

      boolean hasAnythingToClear()
      Returns:
      true if this session's teardown is responsible for something: this session's server credential, or (real-machine UAT fix, plan 16-08) a device-login poll that was still in flight and just got cancelled by invalidate() (see pendingAuthenticationCancelled's own javadoc). false only if both are absent. CloudAuthManager#logout() combines this with the credential stored in credentials.json, which it reads when the session holds none.
    • consumePendingCancellation

      boolean consumePendingCancellation()
    • completeCredentialLogout

      void completeCredentialLogout(ServerCredential retiring)
    • stopPolling

      void stopPolling()
      Stops this session's device-login steps (the request and the polling) and their executor.
    • loadServerCredential

      boolean loadServerCredential()
      Loads framework-owned state without rewriting it or performing any network request.

      Adopting the credential also adopts its reliable-delivery state (D-15): the outbox and the command-dedupe set are created and loaded from .ultikits/cloud-outbox.json here, so an event captured before a restart is still in the buffer afterwards.

    • startConnection

      void startConnection()
      Starts this session's single connection owner; repeated calls never restart a terminal state.
    • stopConnection

      void stopConnection()
      Stops and detaches the connection without ever waiting for its scheduler thread.
    • offerReliableEvent

      void offerReliableEvent(String type, com.google.gson.JsonObject payload)
      Adds one reliable event to this session's outbox, if it can be captured at all (D-14, S-16): a session that holds no credential returns silently, so an unlinked server writes no state file, and so does one whose operator turned ultipanel.capabilities.player-events off. The connection's own state is deliberately not consulted: buffering while disconnected is the point of the outbox.
      Parameters:
      type - the envelope type, for example player.join
      payload - the event payload
    • agentProtocol

      AgentProtocol agentProtocol()
      Returns:
      the protocol layer of the current connection's open attempt, or null between attempts (plan 22-20; 22-21 registers the track handlers on its router)
    • newProtocol

      private AgentProtocol newProtocol(AgentConnection connection)
      Builds the protocol layer of one upgraded attempt of connection (plan 22-20) and, while the plugin's CloudFeatures are installed, registers every inbound handler on it (plan 22-21): the hello declares what the operator's switches allow (AgentCapabilities, S-65), reports the operator settings, and the router re-checks the switch of every request (S-66). Without installed features (a session driven by a test alone) the hello declares the framework capabilities of frameworkCapabilities(), reports power actions as off, and the operator gate admits every capability the welcome accepted.
      Parameters:
      connection - the attempt's connection
      Returns:
      the protocol, not yet opened
    • frameworkCapabilities

      static List<String> frameworkCapabilities()
      The hello's capabilities from the operator's Capability switches (S-65, without the error-report and module entries 22-21 adds): power and link are always declared, backups never.
      Returns:
      the capability names, in a stable order
    • addIf

      private static void addIf(List<String> names, Capability capability, String... contractNames)
    • frameworkSettings

      static com.google.gson.JsonObject frameworkSettings()
      Returns:
      the hello's operator settings until 22-21: power actions reported as off
    • cloudOutbox

      CloudOutbox cloudOutbox()
      Returns:
      this session's outbox, or null while no credential is adopted (plan 22-08 sends and acknowledges its entries)
    • commandDedupe

      CommandDedupeSet commandDedupe()
      Returns:
      this session's command-dedupe set, or null while no credential is adopted (plan 22-09 applies it to inbound command messages)
    • cloudStateStore

      CloudStateStore cloudStateStore()
      Returns:
      this session's state file writer, or null while no credential is adopted
    • ledgerTarget

      Returns:
      this session's id set, state file and server id as one value for the shared ledger, or null while no credential is adopted (plan 22-21)
    • flushCloudState

      void flushCloudState()
      Writes the pending outbox and command-dedupe changes, if there are any. Called from invalidate() -- which PluginInitiationUtils#stopWebsocket() runs at plugin shutdown -- so a stop persists what a 30 s tick has not yet written (D-15). A failure is logged and swallowed: teardown must not be blocked by an unwritable state file.
    • discardCloudState

      void discardCloudState()
      Forgets this session's reliable-delivery state for good (S-12, S-32): the 30 s task is cancelled, the store is detached and its file deleted under the store's write lock, and the in-memory buffer and command-id set are cleared. Every later flush of that store is a no-op, so neither the tick, nor invalidate(), nor the shutdown flush can bring cloud-outbox.json back for an identity that no longer exists. A new credential adopted afterwards starts from a fresh store and an empty file.
    • adoptCloudState

      private void adoptCloudState(ServerCredential adopted)
      Creates and loads this session's reliable-delivery state for adopted (D-15). The caller holds this session's monitor. A state file written by hand for another identity is discarded by CloudStateStore.load(String), and an unresolvable state location leaves the session without persistence rather than failing the login.
      Parameters:
      adopted - the credential this session just adopted
    • scheduleStateFlush

      private void scheduleStateFlush()
      Arms the state cadence from whatever this session currently holds; called once per connection, since the flush task lives on the connection's own executor.
    • scheduleStateFlush

      private void scheduleStateFlush(CloudStateStore store, String serverId, ScheduledExecutorService executor)
    • cancelStateFlushTask

      private void cancelStateFlushTask()
    • flushStateQuietly

      private void flushStateQuietly(CloudStateStore store, String serverId)
      One state write; never throws. A failed write keeps the store's dirty flags set, so the next tick retries, and only the first failure of a streak is reported (T-22-31).
      Parameters:
      store - the store to write
      serverId - the linked server's id
    • reconnectNow

    • restartForManualReconnect

      private CloudAuthManager.ReconnectResult.Kind restartForManualReconnect(AgentConnection captured)
    • connectionState

      AgentConnection.State connectionState()
    • connectionDescription

      String connectionDescription()
    • retryDelayUnits

      private long retryDelayUnits(long unitMillis)
    • setAgentEndpointOverrideForTesting

      static void setAgentEndpointOverrideForTesting(URI endpoint)
    • setAgentTimingOverrideForTesting

      static void setAgentTimingOverrideForTesting(ReconnectPolicy policy, AgentConnection.Timing timing)
    • setAgentRuntimeForTesting

      static void setAgentRuntimeForTesting(Supplier<ScheduledExecutorService> executors, AgentConnection.SocketFactory sockets, LongSupplier clock)
    • requestSuffix

      private static String requestSuffix(String requestId)
    • hasCredential

      boolean hasCredential()
      Returns:
      true if this session committed a server credential
    • getCredential

      ServerCredential getCredential()
      Returns:
      the server credential this session committed, or null
    • commitCredential

      boolean commitCredential(ServerCredential newCredential)
      Persists newCredential through ServerCredentialStore, but only if this session has not been invalidated and is still current, so a grant that arrives after /ulticloud logout or after a newer login replaced this session is discarded and never written.
      Parameters:
      newCredential - the credential the device authorization granted
      Returns:
      true if committed; false if this session was already invalidated, in which case nothing was written
      Throws:
      DataAccessException - if the write fails
    • rotateCredential

      boolean rotateCredential(String secret, String credentialId)
      Rotates this session's credential (plan 22-21, D-10): the credential.rotate persister of every connection this session owns. Under this session's monitor, ServerCredentialStore.replace(java.lang.String, java.lang.String, java.lang.String) first makes the new secret durable (one atomic, owner-only write) and only then is credential replaced, so the in-memory credential and the file change in one step and the caller acknowledges only a durable rotation. The next connection attempt (readBearer) and /ulticloud logout both read credential, so neither can use the retired secret. Nothing is written, and the gateway gets no acknowledgement, when this session was invalidated or replaced, holds no credential, or the file holds another server identity.
      Parameters:
      secret - the new ucs_ secret
      credentialId - the new credential's public id
      Returns:
      true when the new credential is durable and held
      Throws:
      DataAccessException - if the write fails; the old credential then stays in place in memory and on disk
    • beginDeviceLogin

      void beginDeviceLogin(CloudAuthManager.DeviceLoginListener listener)
      Requests a device code on this session's poll executor and, once UltiCloud answers, reports it through listener and starts polling. Schedules nothing on an invalidated session.
      Parameters:
      listener - receives the code or the failure; called on the poll executor's thread
    • requestDeviceCode

      private void requestDeviceCode(CloudAuthManager.DeviceLoginListener listener)
    • startDevicePolling

      void startDevicePolling(DeviceAuthorizationClient.Grant grant, CloudAuthManager.DeviceLoginListener listener)
      Polls POST /v1/device/token for grant on this session's poll executor until it reaches a terminal outcome. Schedules nothing on an invalidated session: a late call after /ulticloud logout -- the device-code request returned after the logout -- creates no executor (the round-1 review property the 6.3.0 magic-link startPolling carried).
      Parameters:
      grant - the device code to poll
      listener - receives the outcome; called on the poll executor's thread
    • scheduleDeviceStep

      private boolean scheduleDeviceStep(Runnable step, long delayMillis)
      Schedules one device-login step on this session's poll executor, creating it on first use. The step becomes pollTask, so invalidate() both cancels it and records that a pending authentication was cancelled.
      Returns:
      false if this session is invalidated and nothing was scheduled
    • pollDeviceOnce

      private void pollDeviceOnce(CloudSession.DevicePoll poll)
      One poll step. RFC 8628 section 3.5 semantics: authorization_pending polls again after the interval; slow_down first adds 5000L ms to it, cumulatively; a transient failure (network, 5xx, 429, unreadable answer) polls again after the interval, or after max(interval, Retry-After) when UltiCloud answered 429; denied, expired and rejected stop. Reaching the deadline while still pending or failing transiently is the same as expired -- checked before each poll, so no request is sent after it.
    • finishDeviceLogin

      private void finishDeviceLogin(CloudSession.DevicePoll poll, DeviceAuthorizationClient.PollResult.Kind kind, String code, String message)
      Stops polling and reports a terminal outcome other than a grant, if this session is current.
    • deviceClient

      private DeviceAuthorizationClient deviceClient()
      Returns:
      the device-login REST client: the test override, else this session's own
    • now

      private static long now()
      Returns:
      the device-login clock in epoch milliseconds: the test override, else the wall clock
    • completeDeviceLogin

      private void completeDeviceLogin(CloudSession.DevicePoll poll, ServerCredential granted)
    • setDeviceSeamsForTesting

      static void setDeviceSeamsForTesting(DeviceAuthorizationClient client, CloudSession.DeviceStepScheduler scheduler, LongSupplier clock)
      Test-only: replaces the device-login REST client, step scheduler and clock for every session, so polling arithmetic is tested without sleeping. Pass nulls to restore the defaults (this session's own client, its poll executor, System.currentTimeMillis()); resetForTesting() does so too.
      Parameters:
      client - the client, or null
      scheduler - the scheduler, or null
      clock - the clock in epoch milliseconds, or null