Skip to content

Commit c56d084

Browse files
committed
uts: introduce Clock abstraction for time operations and testability
- Added `Clock` interface and concrete implementations (`SystemClock` and `FakeClock`) for unified time management. - Refactored classes (`Auth`, `Presence`, `Hosts`, `WebSocketTransport`, etc.) to use `Clock` instead of direct system calls. - Enabled mockable time-based operations for improved testability. - Updated `DebugOptions` to support custom clocks in debug mode.
1 parent edb54a4 commit c56d084

19 files changed

Lines changed: 206 additions & 53 deletions

File tree

lib/src/main/java/io/ably/lib/debug/DebugOptions.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import io.ably.lib.types.AblyException;
1212
import io.ably.lib.types.ClientOptions;
1313
import io.ably.lib.types.ProtocolMessage;
14+
import io.ably.lib.util.Clock;
1415

1516
public class DebugOptions extends ClientOptions {
1617
public interface RawProtocolListener {
@@ -35,6 +36,7 @@ public interface RawHttpListener {
3536
public ITransport.Factory transportFactory;
3637
public HttpEngine httpEngine;
3738
public WebSocketEngineFactory webSocketEngineFactory;
39+
public Clock clock;
3840

3941
public DebugOptions copy() {
4042
DebugOptions copied = new DebugOptions();
@@ -43,6 +45,7 @@ public DebugOptions copy() {
4345
copied.transportFactory = transportFactory;
4446
copied.httpEngine = httpEngine;
4547
copied.webSocketEngineFactory = webSocketEngineFactory;
48+
copied.clock = clock;
4649
copied.clientId = clientId;
4750
copied.logLevel = logLevel;
4851
copied.logHandler = logHandler;

lib/src/main/java/io/ably/lib/http/HttpScheduler.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
import io.ably.lib.types.Callback;
1414
import io.ably.lib.types.ErrorInfo;
1515
import io.ably.lib.types.Param;
16+
import io.ably.lib.util.Clock;
1617
import io.ably.lib.util.Log;
18+
import io.ably.lib.util.SystemClock;
1719

1820
/**
1921
* HttpScheduler schedules HttpCore operations to an Executor, exposing a generic async API.
@@ -286,12 +288,12 @@ public T get() throws InterruptedException, ExecutionException {
286288
}
287289
@Override
288290
public T get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException {
289-
long remaining = unit.toMillis(timeout), deadline = System.currentTimeMillis() + remaining;
291+
long remaining = unit.toMillis(timeout), deadline = clock.currentTimeMillis() + remaining;
290292
synchronized(this) {
291293
while(remaining > 0) {
292294
wait(remaining);
293295
if(isDone) { break; }
294-
remaining = deadline - System.currentTimeMillis();
296+
remaining = deadline - clock.currentTimeMillis();
295297
}
296298
if(!isDone) {
297299
throw new TimeoutException();
@@ -360,6 +362,7 @@ protected synchronized boolean disposeConnection() {
360362
protected HttpScheduler(HttpCore httpCore, CloseableExecutor executor) {
361363
this.httpCore = httpCore;
362364
this.executor = executor;
365+
this.clock = SystemClock.clockFrom(httpCore.options);
363366
}
364367

365368
@Override
@@ -446,6 +449,7 @@ public <T> Future<T> ablyHttpExecuteWithRetry(
446449

447450
protected final CloseableExecutor executor;
448451
private final HttpCore httpCore;
452+
private final Clock clock;
449453

450454
protected static final String TAG = HttpScheduler.class.getName();
451455

lib/src/main/java/io/ably/lib/realtime/ChannelBase.java

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import java.util.Locale;
77
import java.util.Map;
88
import java.util.Set;
9-
import java.util.Timer;
109
import java.util.TimerTask;
1110
import java.util.concurrent.atomic.AtomicBoolean;
1211

@@ -46,12 +45,15 @@
4645
import io.ably.lib.types.PublishResult;
4746
import io.ably.lib.types.Summary;
4847
import io.ably.lib.types.UpdateDeleteResult;
48+
import io.ably.lib.util.Clock;
4949
import io.ably.lib.util.CollectionUtils;
5050
import io.ably.lib.util.EventEmitter;
5151
import io.ably.lib.util.Listeners;
5252
import io.ably.lib.util.Log;
53+
import io.ably.lib.util.NamedTimer;
5354
import io.ably.lib.util.ReconnectionStrategy;
5455
import io.ably.lib.util.StringUtils;
56+
import io.ably.lib.util.SystemClock;
5557
import org.jetbrains.annotations.Blocking;
5658
import org.jetbrains.annotations.NonBlocking;
5759
import org.jetbrains.annotations.Nullable;
@@ -508,21 +510,20 @@ private void setFailed(ErrorInfo reason) {
508510
}
509511

510512
/* Timer for attach operation */
511-
private Timer attachTimer;
513+
private NamedTimer attachTimer;
512514

513515
/* Timer for reattaching if attach failed */
514-
private Timer reattachTimer;
516+
private NamedTimer reattachTimer;
515517

516518
/**
517519
* Cancel attach/reattach timers
518520
*/
519521
synchronized private void clearAttachTimers() {
520-
Timer[] timers = new Timer[]{attachTimer, reattachTimer};
522+
NamedTimer[] timers = new NamedTimer[]{attachTimer, reattachTimer};
521523
attachTimer = reattachTimer = null;
522-
for (Timer t: timers) {
524+
for (NamedTimer t: timers) {
523525
if (t != null) {
524526
t.cancel();
525-
t.purge();
526527
}
527528
}
528529
}
@@ -537,9 +538,9 @@ private void attachWithTimeout(final CompletionListener listener) throws AblyExc
537538
*/
538539
synchronized private void attachWithTimeout(final boolean forceReattach, final CompletionListener listener, ErrorInfo reattachmentReason) {
539540
checkChannelIsNotReleased();
540-
Timer currentAttachTimer;
541+
NamedTimer currentAttachTimer;
541542
try {
542-
currentAttachTimer = new Timer();
543+
currentAttachTimer = clock.newTimer("attach-timer");
543544
} catch(Throwable t) {
544545
/* an exception instancing the timer can arise because the runtime is exiting */
545546
callCompletionListenerError(listener, ErrorInfo.fromThrowable(t));
@@ -571,7 +572,7 @@ public void onError(ErrorInfo reason) {
571572
return;
572573
}
573574

574-
final Timer inProgressTimer = currentAttachTimer;
575+
final NamedTimer inProgressTimer = currentAttachTimer;
575576
attachTimer.schedule(
576577
new TimerTask() {
577578
@Override
@@ -601,9 +602,9 @@ private void checkChannelIsNotReleased() {
601602
* try to attach the channel
602603
*/
603604
synchronized private void reattachAfterTimeout() {
604-
Timer currentReattachTimer;
605+
NamedTimer currentReattachTimer;
605606
try {
606-
currentReattachTimer = new Timer();
607+
currentReattachTimer = clock.newTimer("reattach-timer");
607608
} catch(Throwable t) {
608609
/* an exception instancing the timer can arise because the runtime is exiting */
609610
return;
@@ -613,7 +614,7 @@ synchronized private void reattachAfterTimeout() {
613614
this.retryAttempt++;
614615
int retryDelay = ReconnectionStrategy.getRetryTime(ably.options.channelRetryTimeout, retryAttempt);
615616

616-
final Timer inProgressTimer = currentReattachTimer;
617+
final NamedTimer inProgressTimer = currentReattachTimer;
617618
reattachTimer.schedule(new TimerTask() {
618619
@Override
619620
public void run() {
@@ -640,9 +641,9 @@ public void run() {
640641
*/
641642
synchronized private void detachWithTimeout(final CompletionListener listener) {
642643
final ChannelState originalState = state;
643-
Timer currentDetachTimer;
644+
NamedTimer currentDetachTimer;
644645
try {
645-
currentDetachTimer = released.get() ? null : new Timer();
646+
currentDetachTimer = released.get() ? null : clock.newTimer("detach-timer");
646647
} catch(Throwable t) {
647648
/* an exception instancing the timer can arise because the runtime is exiting */
648649
callCompletionListenerError(listener, ErrorInfo.fromThrowable(t));
@@ -676,7 +677,7 @@ public void onError(ErrorInfo reason) {
676677
return;
677678
}
678679

679-
final Timer inProgressTimer = currentDetachTimer;
680+
final NamedTimer inProgressTimer = currentDetachTimer;
680681
attachTimer.schedule(new TimerTask() {
681682
@Override
682683
public void run() {
@@ -1684,6 +1685,7 @@ else if(stateChange.current.equals(failureState)) {
16841685
ChannelBase(AblyRealtime ably, String name, ChannelOptions options, @Nullable LiveObjectsPlugin liveObjectsPlugin) throws AblyException {
16851686
Log.v(TAG, "RealtimeChannel(); channel = " + name);
16861687
this.ably = ably;
1688+
this.clock = SystemClock.clockFrom(ably.options);
16871689
this.name = name;
16881690
this.basePath = "/channels/" + HttpUtils.encodeURIComponent(name);
16891691
this.setOptions(options);
@@ -1808,6 +1810,7 @@ public void sendProtocolMessage(ProtocolMessage protocolMessage, CompletionListe
18081810

18091811
private static final String TAG = Channel.class.getName();
18101812
final AblyRealtime ably;
1813+
final Clock clock;
18111814
final String basePath;
18121815
ChannelOptions options;
18131816
/**

lib/src/main/java/io/ably/lib/realtime/Presence.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,7 @@ private void endSync() {
331331
for (PresenceMessage member: residualMembers) { // RTP19
332332
member.action = PresenceMessage.Action.leave;
333333
member.id = null;
334-
member.timestamp = System.currentTimeMillis();
334+
member.timestamp = channel.clock.currentTimeMillis();
335335
}
336336
broadcastPresence(residualMembers);
337337
}

lib/src/main/java/io/ably/lib/rest/Auth.java

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,10 @@
2727
import io.ably.lib.types.NonRetriableTokenException;
2828
import io.ably.lib.types.Param;
2929
import io.ably.lib.util.Base64Coder;
30+
import io.ably.lib.util.Clock;
3031
import io.ably.lib.util.Log;
3132
import io.ably.lib.util.Serialisation;
33+
import io.ably.lib.util.SystemClock;
3234

3335
/**
3436
* Token-generation and authentication operations for the Ably API.
@@ -921,7 +923,7 @@ else if(!request.keyName.equals(keyName))
921923
if(request.timestamp == 0) {
922924
if(options.queryTime) {
923925
long oldNanoTimeDelta = nanoTimeDelta;
924-
long currentNanoTimeDelta = System.currentTimeMillis() - System.nanoTime()/(1000*1000);
926+
long currentNanoTimeDelta = clock.currentTimeMillis() - System.nanoTime()/(1000*1000);
925927

926928
if (timeDelta != Long.MAX_VALUE) {
927929
/* system time changed by more than 500ms since last time? */
@@ -1036,7 +1038,7 @@ public void onAuthError(ErrorInfo err) {
10361038
clearTokenDetails();
10371039
}
10381040

1039-
public static long timestamp() { return System.currentTimeMillis(); }
1041+
public long timestamp() { return clock.currentTimeMillis(); }
10401042

10411043
/********************
10421044
* internal
@@ -1050,6 +1052,8 @@ public void onAuthError(ErrorInfo err) {
10501052
*/
10511053
Auth(AblyBase ably, ClientOptions options) throws AblyException {
10521054
this.ably = ably;
1055+
this.clock = SystemClock.clockFrom(options);
1056+
this.nanoTimeDelta = clock.currentTimeMillis() - System.nanoTime()/(1000*1000);
10531057
authOptions = options;
10541058
tokenParams = options.defaultTokenParams != null ?
10551059
options.defaultTokenParams : new TokenParams();
@@ -1304,6 +1308,7 @@ public long serverTimestamp() {
13041308

13051309
private static final String TAG = Auth.class.getName();
13061310
private final AblyBase ably;
1311+
private final Clock clock;
13071312
private final AuthMethod method;
13081313
private AuthOptions authOptions;
13091314
private TokenParams tokenParams;
@@ -1320,7 +1325,7 @@ public long serverTimestamp() {
13201325
* Time delta between System.nanoTime() and System.currentTimeMillis. If it changes significantly it
13211326
* suggests device time/date has changed
13221327
*/
1323-
private long nanoTimeDelta = System.currentTimeMillis() - System.nanoTime()/(1000*1000);
1328+
private long nanoTimeDelta;
13241329

13251330
public static final String WILDCARD_CLIENTID = "*";
13261331
/**

lib/src/main/java/io/ably/lib/transport/ConnectionManager.java

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,11 @@
3636
import io.ably.lib.types.ProtocolMessage;
3737
import io.ably.lib.types.ProtocolSerializer;
3838
import io.ably.lib.types.PublishResult;
39+
import io.ably.lib.util.Clock;
3940
import io.ably.lib.util.Log;
4041
import io.ably.lib.util.PlatformAgentProvider;
4142
import io.ably.lib.util.ReconnectionStrategy;
43+
import io.ably.lib.util.SystemClock;
4244
import org.jetbrains.annotations.Nullable;
4345

4446
public class ConnectionManager implements ConnectListener {
@@ -782,6 +784,7 @@ public void run() {
782784

783785
public ConnectionManager(final AblyRealtime ably, final Connection connection, final Channels channels, final PlatformAgentProvider platformAgentProvider, LiveObjectsPlugin liveObjectsPlugin) throws AblyException {
784786
this.ably = ably;
787+
this.clock = SystemClock.clockFrom(ably.options);
785788
this.connection = connection;
786789
this.channels = channels;
787790
this.platformAgentProvider = platformAgentProvider;
@@ -1447,7 +1450,7 @@ private boolean checkConnectionStale() {
14471450
if(lastActivity == 0) {
14481451
return false;
14491452
}
1450-
long now = System.currentTimeMillis();
1453+
long now = clock.currentTimeMillis();
14511454
long intervalSinceLastActivity = now - lastActivity;
14521455
if(intervalSinceLastActivity > (maxIdleInterval + connectionStateTtl)) {
14531456
/* RTN15g1, RTN15g2 Force a new connection if the previous one is stale;
@@ -1465,7 +1468,7 @@ private boolean checkConnectionStale() {
14651468
}
14661469

14671470
private synchronized void setSuspendTime() {
1468-
suspendTime = (System.currentTimeMillis() + connectionStateTtl);
1471+
suspendTime = (clock.currentTimeMillis() + connectionStateTtl);
14691472
}
14701473

14711474
/**
@@ -1490,7 +1493,7 @@ private StateIndication checkFallback(ErrorInfo reason) {
14901493
}
14911494

14921495
private synchronized StateIndication checkSuspended(ErrorInfo reason) {
1493-
long currentTime = System.currentTimeMillis();
1496+
long currentTime = clock.currentTimeMillis();
14941497
long timeToSuspend = suspendTime - currentTime;
14951498
boolean suspendMode = timeToSuspend <= 0;
14961499
Log.v(TAG, "checkSuspended: timeToSuspend = " + timeToSuspend + "ms; suspendMode = " + suspendMode);
@@ -2015,6 +2018,7 @@ private boolean isFatalError(ErrorInfo err) {
20152018
******************/
20162019

20172020
final AblyRealtime ably;
2021+
private final Clock clock;
20182022
private final Channels channels;
20192023
private final Connection connection;
20202024
private final ITransport.Factory transportFactory;

lib/src/main/java/io/ably/lib/transport/Hosts.java

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import io.ably.lib.types.AblyException;
44
import io.ably.lib.types.ClientOptions;
55
import io.ably.lib.types.ErrorInfo;
6+
import io.ably.lib.util.Clock;
7+
import io.ably.lib.util.SystemClock;
68

79
import java.util.Arrays;
810
import java.util.Collections;
@@ -23,6 +25,7 @@ public class Hosts {
2325
private final long fallbackRetryTimeout;
2426

2527
private final Preferred preferred = new Preferred();
28+
private final Clock clock;
2629

2730
/**
2831
* Create Hosts object
@@ -77,6 +80,7 @@ public Hosts(final String primaryHost, final String defaultHost, final ClientOpt
7780
/* RSC15a: shuffle the fallback hosts. */
7881
Collections.shuffle(Arrays.asList(fallbackHosts));
7982
fallbackRetryTimeout = options.fallbackRetryTimeout;
83+
this.clock = SystemClock.clockFrom(options);
8084
}
8185

8286
/**
@@ -91,7 +95,7 @@ public synchronized void setPreferredHost(final String prefHost, final boolean t
9195
/* a successful request against the primary host; reset */
9296
preferred.clear();
9397
} else {
94-
preferred.setHost(prefHost, temporary ? System.currentTimeMillis() + fallbackRetryTimeout : 0);
98+
preferred.setHost(prefHost, temporary ? clock.currentTimeMillis() + fallbackRetryTimeout : 0);
9599
}
96100
}
97101

@@ -106,7 +110,7 @@ public String getPrimaryHost() {
106110
* Get preferred host name (taking into account any affinity to a fallback: see RSC15f)
107111
*/
108112
public synchronized String getPreferredHost() {
109-
final String host = preferred.getHostOrClearIfExpired();
113+
final String host = preferred.getHostOrClearIfExpired(clock);
110114
return (host == null) ? primaryHost : host;
111115
}
112116

@@ -128,7 +132,7 @@ public synchronized String getFallback(String lastHost) {
128132
if (!primaryHostIsDefault && !fallbackHostsUseDefault && fallbackHostsIsDefault)
129133
return null;
130134
idx = 0;
131-
} else if(lastHost.equals(preferred.getHostOrClearIfExpired())) {
135+
} else if(lastHost.equals(preferred.getHostOrClearIfExpired(clock))) {
132136
/* RSC15f: there was a failure on an unexpired, cached fallback; so try again using the primary */
133137
preferred.clear();
134138
return primaryHost;
@@ -174,8 +178,8 @@ public void setHost(final String host, final long expiry) {
174178
this.expiry = expiry;
175179
}
176180

177-
public String getHostOrClearIfExpired() {
178-
if(expiry > 0 && expiry <= System.currentTimeMillis()) {
181+
public String getHostOrClearIfExpired(Clock clock) {
182+
if(expiry > 0 && expiry <= clock.currentTimeMillis()) {
179183
clear(); // expired, so reset
180184
}
181185
return host;

0 commit comments

Comments
 (0)