Skip to content

Commit 7d8e7b9

Browse files
committed
[ECO-5514] fix: improve WebSocket transport lifecycle and activity management
* Force-cancel hanging socket connections * Replace synchronized blocks with fine-grained locking using `activityTimerMonitor` to avoid deadlocks. * Refactor `close()` for safer access to shared fields and handle uninitialized cases gracefully. * Simplify activity timer logic and ensure consistent disposal of `WebSocketClient` and `WebSocketHandler`.
1 parent 3f9d007 commit 7d8e7b9

1 file changed

Lines changed: 81 additions & 51 deletions

File tree

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

Lines changed: 81 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,9 @@ public class WebSocketTransport implements ITransport {
5252
private ConnectListener connectListener;
5353
private WebSocketClient webSocketClient;
5454
private final WebSocketEngine webSocketEngine;
55+
private WebSocketHandler webSocketHandler;
5556
private boolean activityCheckTurnedOff = false;
57+
private boolean connectHasBeenCalled = false;
5658

5759
/******************
5860
* protected constructor
@@ -94,8 +96,13 @@ private static WebSocketEngine createWebSocketEngine(TransportParams params) {
9496
* ITransport methods
9597
******************/
9698

99+
/**
100+
* Connect is called once when we create transport;
101+
* after transport is closed, we never call `connect` again
102+
*/
97103
@Override
98104
public void connect(ConnectListener connectListener) {
105+
ensureConnectCalledOnce();
99106
this.connectListener = connectListener;
100107
try {
101108
boolean isTls = params.options.tls;
@@ -107,9 +114,8 @@ public void connect(ConnectListener connectListener) {
107114
wsUri = HttpUtils.encodeParams(wsUri, connectParams);
108115

109116
Log.d(TAG, "connect(); wsUri = " + wsUri);
110-
synchronized (this) {
111-
webSocketClient = this.webSocketEngine.create(wsUri, new WebSocketHandler(this::receive));
112-
}
117+
webSocketHandler = new WebSocketHandler(this::receive);
118+
webSocketClient = this.webSocketEngine.create(wsUri, webSocketHandler);
113119
webSocketClient.connect();
114120
} catch (AblyException e) {
115121
Log.e(TAG, "Unexpected exception attempting connection; wsUri = " + wsUri, e);
@@ -120,14 +126,36 @@ public void connect(ConnectListener connectListener) {
120126
}
121127
}
122128

129+
/**
130+
* `connect()` can't be called more than once
131+
*/
132+
private synchronized void ensureConnectCalledOnce() {
133+
if (connectHasBeenCalled) throw new IllegalStateException("WebSocketTransport is already initialized");
134+
connectHasBeenCalled = true;
135+
}
136+
123137
@Override
124138
public void close() {
125139
Log.d(TAG, "close()");
126-
synchronized (this) {
127-
if (webSocketClient != null) {
128-
webSocketClient.close();
129-
webSocketClient = null;
130-
}
140+
// Take local snapshots of the shared references. Callback threads (e.g., onClose)
141+
// may concurrently set these fields to null.
142+
//
143+
// Intentionally avoid synchronizing here:
144+
// - The WebSocket library may invoke our WebSocketHandler while holding its own
145+
// internal locks.
146+
// - If close() also acquired a lock on WebSocketTransport, we could invert the
147+
// lock order and create a circular wait (deadlock): close() waits for the WS
148+
// library to release its lock, while the WS library waits for a lock on
149+
// WebSocketTransport.
150+
final WebSocketClient client = webSocketClient;
151+
final WebSocketHandler handler = webSocketHandler;
152+
if (client != null && handler != null) {
153+
// Record activity so the activity timer remains armed. If a graceful close
154+
// stalls, the timer can detect inactivity and force-cancel the socket.
155+
handler.flagActivity();
156+
client.close();
157+
} else {
158+
Log.w(TAG, "close() called on uninitialized or already closed transport");
131159
}
132160
}
133161

@@ -217,10 +245,15 @@ class WebSocketHandler implements WebSocketListener {
217245
* WsClient private members
218246
***************************/
219247

220-
private Timer timer = new Timer();
248+
private final Timer timer = new Timer();
221249
private TimerTask activityTimerTask = null;
222250
private long lastActivityTime;
223251

252+
/**
253+
* Monitor for activity timer events
254+
*/
255+
private final Object activityTimerMonitor = new Object();
256+
224257
WebSocketHandler(WebSocketReceiver receiver) {
225258
this.receiver = receiver;
226259
}
@@ -318,66 +351,63 @@ public void onOldJavaVersionDetected(Throwable throwable) {
318351
Log.w(TAG, "Error when trying to set SSL parameters, most likely due to an old Java API version", throwable);
319352
}
320353

321-
private synchronized void dispose() {
322-
/* dispose timer */
323-
try {
324-
timer.cancel();
325-
timer = null;
326-
} catch (IllegalStateException e) {
327-
}
354+
private void dispose() {
355+
timer.cancel();
328356
}
329357

330-
private synchronized void flagActivity() {
358+
private void flagActivity() {
331359
lastActivityTime = System.currentTimeMillis();
332360
connectionManager.setLastActivity(lastActivityTime);
333-
if (activityTimerTask == null && connectionManager.maxIdleInterval != 0 && !activityCheckTurnedOff) {
334-
/* No timer currently running because previously there was no
335-
* maxIdleInterval configured, but now there is a
336-
* maxIdleInterval configured. Call checkActivity so a timer
337-
* gets started. This happens when flagActivity gets called
338-
* just after processing the connect message that configures
339-
* maxIdleInterval. */
340-
checkActivity();
361+
362+
if (connectionManager.maxIdleInterval == 0) {
363+
Log.v(TAG, "checkActivity: turned off because maxIdleInterval is 0");
364+
return;
341365
}
366+
367+
if (activityCheckTurnedOff) {
368+
Log.v(TAG, "checkActivity: turned off for test purpose");
369+
return;
370+
}
371+
372+
checkActivity();
342373
}
343374

344-
private synchronized void checkActivity() {
375+
private void checkActivity() {
345376
long timeout = getActivityTimeout();
377+
346378
if (timeout == 0) {
347379
Log.v(TAG, "checkActivity: infinite timeout");
348380
return;
349381
}
350382

351-
// Check if timer already running
352-
if (activityTimerTask != null) {
353-
return;
383+
synchronized (activityTimerMonitor) {
384+
// Check if timer already running
385+
if (activityTimerTask == null) {
386+
// Start the activity timer task
387+
startActivityTimer(timeout + 100);
388+
}
354389
}
355-
356-
// Start the activity timer task
357-
startActivityTimer(timeout + 100);
358390
}
359391

360-
private synchronized void startActivityTimer(long timeout) {
361-
if (activityTimerTask == null) {
362-
schedule((activityTimerTask = new TimerTask() {
363-
public void run() {
364-
try {
365-
onActivityTimerExpiry();
366-
} catch (Throwable t) {
367-
Log.e(TAG, "Unexpected exception in activity timer handler", t);
368-
}
392+
private void startActivityTimer(long timeout) {
393+
activityTimerTask = new TimerTask() {
394+
public void run() {
395+
try {
396+
onActivityTimerExpiry();
397+
} catch (Exception exception) {
398+
Log.e(TAG, "Unexpected exception in activity timer handler", exception);
399+
webSocketClient.cancel(ABNORMAL_CLOSE, "Activity timer closed unexpectedly");
369400
}
370-
}), timeout);
371-
}
401+
}
402+
};
403+
schedule(activityTimerTask, timeout);
372404
}
373405

374-
private synchronized void schedule(TimerTask task, long delay) {
375-
if (timer != null) {
376-
try {
377-
timer.schedule(task, delay);
378-
} catch (IllegalStateException ise) {
379-
Log.e(TAG, "Unexpected exception scheduling activity timer", ise);
380-
}
406+
private void schedule(TimerTask task, long delay) {
407+
try {
408+
timer.schedule(task, delay);
409+
} catch (IllegalStateException ise) {
410+
Log.w(TAG, "Timer has already been canceled", ise);
381411
}
382412
}
383413

@@ -392,7 +422,7 @@ private void onActivityTimerExpiry() {
392422
return;
393423
}
394424

395-
synchronized (this) {
425+
synchronized (activityTimerMonitor) {
396426
activityTimerTask = null;
397427
// Otherwise, we've had some activity, restart the timer for the next timeout
398428
Log.v(TAG, "onActivityTimerExpiry: ok");

0 commit comments

Comments
 (0)