Skip to content

Conversation

@ttypic
Copy link
Contributor

@ttypic ttypic commented Sep 24, 2025

Resolves #1144

  • 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.

Summary by CodeRabbit

  • Bug Fixes

    • Fewer deadlocks/races during connect/close; improved cleanup and warnings for already-closed or uninitialized connections.
    • More robust activity/idle handling to prevent stray timers and improper cancels.
  • Refactor

    • Enforced single-connect semantics and reduced synchronization hotspots for more reliable lifecycle and activity handling.
  • New Features

    • Added ability to retrieve auth parameters and check whether a transport is active.
  • Tests

    • Added unit tests for connect/close and activity-timer scenarios and a test helper to set private fields.

@ttypic ttypic requested a review from sacOO7 September 24, 2025 15:41
@coderabbitai
Copy link

coderabbitai bot commented Sep 24, 2025

Walkthrough

Decouples WebSocket client lifecycle via a new WebSocketHandler, enforces single connect invocation, refines activity-timer synchronization and error handling, adds ConnectionManager passthroughs, a reflection test helper, and new unit tests for connect/close and activity-timer behaviors.

Changes

Cohort / File(s) Summary
WebSocket transport & lifecycle
lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java
Adds WebSocketHandler field and uses it in connect(); introduces connectHasBeenCalled with ensureConnectCalledOnce(); changes close() to use local snapshots, signal handler activity before closing, and adds isActiveTransport() helper; removes broad synchronization around client creation.
WebSocket handler — activity timer & sync
lib/src/main/java/io/ably/lib/transport/WebSocketHandler.java
Makes timer final; makes activity state fields volatile; adds activityTimerMonitor; simplifies dispose(); gates flagActivity() by active transport and options; refactors checkActivity(), startActivityTimer(), schedule(), and onActivityTimerExpiry() to guard timer lifecycle, handle scheduling exceptions (cancel client with ABNORMAL_CLOSE), and use params.options.realtimeRequestTimeout for timeouts.
ConnectionManager helpers
lib/src/main/java/io/ably/lib/transport/ConnectionManager.java
Adds package-private Param[] getAuthParams() delegating to ably.auth.getAuthParams() and boolean isActiveTransport(WebSocketTransport transport); marks transport volatile and imports io.ably.lib.types.Param.
Test reflection helper
lib/src/test/java/io/ably/lib/test/common/Helpers.java
Adds public static void setPrivateField(Object object, String fieldName, Object value) to set private fields via reflection and fail tests on error.
Unit tests for transport
lib/src/test/java/io/ably/lib/transport/WebSocketTransportTest.java
New tests verifying: double connect() throws; non-graceful close triggers cancel(1006, ...) and unavailability notification; multiple onClose events handled safely; close() on an already-closed transport handled. Uses mocks and reflection helper.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor App
  participant T as WebSocketTransport
  participant H as WebSocketHandler
  participant C as WebSocketClient
  participant Timer as ActivityTimer

  App->>T: connect()
  T->>H: instantiate & wire handler
  T->>C: open(url, handler)

  C-->>H: onOpen/onMessage/onClose/onError
  H->>H: flagActivity()
  H->>Timer: check/start/reschedule (guarded)
  Timer-->>H: expiry -> onActivityTimerExpiry()
  alt idle timeout exceeded
    H->>C: cancel/close (ABNORMAL_CLOSE)
  else activity observed
    H->>Timer: reschedule with updated timeout
  end

  App->>T: close()
  T->>T: snapshot client & handler
  alt handler & client non-null
    T->>H: flagActivity()/dispose()
    T->>C: close()
  else
    T->>T: log uninitialized/already-closed
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

I twitch my whiskers, mend the trail,
A handler hops where timers hail.
Connect called once, the paths align,
Threads settle down, no tangled line.
I nibble bugs and hum — all fine. 🐇✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title clearly summarizes the primary change by indicating a fix to the WebSocket transport’s lifecycle and activity management, matching the main objectives of the changeset.
Linked Issues Check ✅ Passed The changes implement fine-grained locking, enforce single connect invocation, simplify and guard the activity timer logic, and add forced cancellation paths in the WebSocketHandler and transport close methods, directly addressing the memory leak and thread cleanup issues described in #1144 and ECO-5514 while included tests verify the behavior.
Out of Scope Changes Check ✅ Passed All modifications, including the new ConnectionManager getters, WebSocketHandler refactor, transport changes, test helper, and unit tests, relate directly to improving WebSocket transport lifecycle management and do not introduce unrelated functionality.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch ECO-5514/fix-websocket-close

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Jira integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 718573c and f14844a.

📒 Files selected for processing (2)
  • lib/src/main/java/io/ably/lib/transport/ConnectionManager.java (3 hunks)
  • lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java (8 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • lib/src/main/java/io/ably/lib/transport/ConnectionManager.java
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-09-25T12:18:35.432Z
Learnt from: ttypic
PR: ably/ably-java#1165
File: lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java:250-251
Timestamp: 2025-09-25T12:18:35.432Z
Learning: In lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java, the lastActivityTime field is accessed from multiple threads (WebSocket callback threads and Timer thread) without synchronization, requiring volatile for proper visibility guarantees.

Applied to files:

  • lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (12)
  • GitHub Check: build
  • GitHub Check: check-liveobjects
  • GitHub Check: check-realtime-okhttp
  • GitHub Check: check-rest-okhttp
  • GitHub Check: check
  • GitHub Check: check-rest
  • GitHub Check: check-realtime
  • GitHub Check: check (29)
  • GitHub Check: check (21)
  • GitHub Check: check (29)
  • GitHub Check: check (24)
  • GitHub Check: check (19)
🔇 Additional comments (10)
lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java (10)

53-56: Make shared refs volatile (or otherwise safely published) to restore cross-thread visibility

webSocketClient and webSocketHandler are read/written from multiple threads (connect thread, WS callbacks, timer thread). Without volatile (or another HB edge), readers can observe stale/null values after synchronization was removed.

Apply:

-    private WebSocketClient webSocketClient;
+    private volatile WebSocketClient webSocketClient;
     private final WebSocketEngine webSocketEngine;
-    private WebSocketHandler webSocketHandler;
+    private volatile WebSocketHandler webSocketHandler;

133-136: Guarding against multiple connect() calls looks good

The synchronized one-shot guard is simple and correct.


255-257: Good: volatile for activityTimerTask and lastActivityTime

This restores visibility guarantees between WS callback and timer threads. Based on learnings


383-401: Activity timer DCL is fine

Volatile + monitor with the fast-path check prevents duplicate schedules without coarse-grained locking.


403-415: Avoid potential NPE when cancelling from TimerTask catch

If a future refactor nulls webSocketClient concurrently, this path would NPE. Snapshot and null-check before calling cancel(...).

Apply:

                     } catch (Exception exception) {
                         Log.e(TAG, "Unexpected exception in activity timer handler", exception);
-                        webSocketClient.cancel(ABNORMAL_CLOSE, "Activity timer closed unexpectedly");
+                        final WebSocketClient c = webSocketClient;
+                        if (c != null) {
+                            c.cancel(ABNORMAL_CLOSE, "Activity timer closed unexpectedly");
+                        }
                     }

436-441: Timer restart on partial inactivity looks correct

Resetting activityTimerTask under the monitor and rescheduling with remaining time is sound.


224-227: Helper looks fine

Delegating transport activeness to ConnectionManager is a clean boundary.


141-161: Dispose handler when client is null to avoid stray Timer threads

If client == null but handler != null, we only log. Dispose the handler to ensure the Timer is cancelled and a thread isn’t leaked.

Apply:

         } else {
             Log.w(TAG, "close() called on uninitialized or already closed transport");
+            if (handler != null) {
+                handler.dispose();
+            }
         }

254-261: Make the Timer a daemon thread (and name it) to prevent process pinning

new Timer() creates a non-daemon thread that can keep the JVM alive if disposal is missed in edge paths. Use a daemon timer (and a helpful name).

Apply:

-        private final Timer timer = new Timer();
+        private final Timer timer = new Timer("WebSocketActivity-" + System.identityHashCode(WebSocketTransport.this), true);

417-423: Fix typo in log message

Minor grammar issue.

Apply:

-                Log.w(TAG, "Timer has already has been canceled", ise);
+                Log.w(TAG, "Timer has already been canceled", ise);

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions bot temporarily deployed to staging/pull/1165/features September 24, 2025 15:42 Inactive
@github-actions github-actions bot temporarily deployed to staging/pull/1165/javadoc September 24, 2025 15:44 Inactive
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java (1)

114-124: Dispose handler on connect failures to prevent Timer thread leaks

new WebSocketHandler() creates a Timer thread immediately. If create() or connect() throws, the handler isn’t disposed and the Timer thread leaks. Dispose and null out on both catch paths.

         } catch (AblyException e) {
             Log.e(TAG, "Unexpected exception attempting connection; wsUri = " + wsUri, e);
             connectListener.onTransportUnavailable(this, e.errorInfo);
+            if (webSocketHandler != null) {
+                webSocketHandler.dispose();
+                webSocketHandler = null;
+            }
+            webSocketClient = null;
         } catch (Throwable t) {
             Log.e(TAG, "Unexpected exception attempting connection; wsUri = " + wsUri, t);
             connectListener.onTransportUnavailable(this, AblyException.fromThrowable(t).errorInfo);
+            if (webSocketHandler != null) {
+                webSocketHandler.dispose();
+                webSocketHandler = null;
+            }
+            webSocketClient = null;
         }
🧹 Nitpick comments (3)
lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java (3)

238-238: Use a daemon Timer with a descriptive name

Non-daemon Timers can pin the JVM; naming aids debugging. This reduces impact if disposal is delayed.

-        private final Timer timer = new Timer();
+        private final Timer timer = new Timer("Ably-WS-activity", true);

347-349: Harden dispose: clear task under monitor and purge Timer queue

Prevents stale activityTimerTask from blocking future scheduling and cleans the Timer queue.

-        private void dispose() {
-            timer.cancel();
-        }
+        private void dispose() {
+            synchronized (activityTimerMonitor) {
+                activityTimerTask = null;
+            }
+            timer.cancel();
+            timer.purge();
+        }

399-406: Reset task on schedule failure and null-check before cancel

If scheduling fails (e.g., Timer already cancelled), clear activityTimerTask so future attempts can reschedule; also guard against webSocketClient being null.

         private void schedule(TimerTask task, long delay) {
             try {
                 timer.schedule(task, delay);
             } catch (IllegalStateException ise) {
                 Log.e(TAG, "Unexpected exception scheduling activity timer", ise);
-                webSocketClient.cancel(ABNORMAL_CLOSE, "Activity timer closed unexpectedly");
+                final WebSocketClient c = webSocketClient;
+                if (c != null) {
+                    c.cancel(ABNORMAL_CLOSE, "Activity timer closed unexpectedly");
+                }
+                synchronized (activityTimerMonitor) {
+                    if (activityTimerTask == task) {
+                        activityTimerTask = null;
+                    }
+                }
             }
         }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Jira integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 3f9d007 and 0c75dbc.

📒 Files selected for processing (1)
  • lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java (8 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java (1)
lib/src/main/java/io/ably/lib/util/Log.java (1)
  • Log (21-157)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (12)
  • GitHub Check: check
  • GitHub Check: check-rest-okhttp
  • GitHub Check: check-liveobjects
  • GitHub Check: check-realtime-okhttp
  • GitHub Check: check (29)
  • GitHub Check: check (29)
  • GitHub Check: check (21)
  • GitHub Check: check-rest
  • GitHub Check: check-realtime
  • GitHub Check: check (24)
  • GitHub Check: check (19)
  • GitHub Check: build
🔇 Additional comments (3)
lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java (3)

351-366: LGTM: activity check gating and early returns are clear

The guard for maxIdleInterval == 0 and test toggle is sound; delegating to checkActivity() keeps flow simple.


368-383: LGTM: fine-grained locking with a dedicated monitor

Limiting synchronization to the timer start path avoids global locks and mitigates deadlocks.


419-425: LGTM: expiry path uses monitor and restarts safely

Nulling activityTimerTask under the monitor before rescheduling avoids double-starts.

@ttypic ttypic force-pushed the ECO-5514/fix-websocket-close branch from 0c75dbc to 7d8e7b9 Compare September 25, 2025 12:08
@github-actions github-actions bot temporarily deployed to staging/pull/1165/features September 25, 2025 12:09 Inactive
@github-actions github-actions bot temporarily deployed to staging/pull/1165/javadoc September 25, 2025 12:11 Inactive
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java (4)

116-126: Dispose handler on connection failure to prevent Timer thread leak.

If engine.create/connect throws after handler construction, its Timer thread remains unless disposed.

         } catch (AblyException e) {
             Log.e(TAG, "Unexpected exception attempting connection; wsUri = " + wsUri, e);
+            final WebSocketHandler h = webSocketHandler;
+            if (h != null) { h.dispose(); }
             connectListener.onTransportUnavailable(this, e.errorInfo);
         } catch (Throwable t) {
             Log.e(TAG, "Unexpected exception attempting connection; wsUri = " + wsUri, t);
+            final WebSocketHandler h = webSocketHandler;
+            if (h != null) { h.dispose(); }
             connectListener.onTransportUnavailable(this, AblyException.fromThrowable(t).errorInfo);
         }

269-280: Handle non-array-backed ByteBuffers to avoid UnsupportedOperationException.

blob.array() is unsafe for direct/read-only buffers and your catch only covers AblyException.

-            try {
-                ProtocolMessage msg = ProtocolSerializer.readMsgpack(blob.array());
+            try {
+                final byte[] data;
+                if (blob.hasArray()) {
+                    int len = blob.remaining();
+                    int off = blob.arrayOffset() + blob.position();
+                    byte[] src = blob.array();
+                    if (off == 0 && len == src.length) {
+                        data = src;
+                    } else {
+                        data = new byte[len];
+                        System.arraycopy(src, off, data, 0, len);
+                    }
+                } else {
+                    int len = blob.remaining();
+                    data = new byte[len];
+                    blob.slice().get(data);
+                }
+                ProtocolMessage msg = ProtocolSerializer.readMsgpack(data);

418-422: Null-check client before force-cancel on timeout.

Defensive against concurrent disposal.

-                webSocketClient.cancel(ABNORMAL_CLOSE, "timed out");
+                final WebSocketClient c = webSocketClient;
+                if (c != null) {
+                    c.cancel(ABNORMAL_CLOSE, "timed out");
+                }

304-305: Minor: remove misleading ‘remote = false’ from log.

No remote flag is tracked here; logging a hardcoded false is confusing.

-            Log.d(TAG, "onClose(): wsCode = " + wsCode + "; wsReason = " + wsReason + "; remote = " + false);
+            Log.d(TAG, "onClose(): wsCode = " + wsCode + "; wsReason = " + wsReason);
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Jira integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 0c75dbc and 7d8e7b9.

📒 Files selected for processing (1)
  • lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java (7 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java (1)
lib/src/main/java/io/ably/lib/util/Log.java (1)
  • Log (21-157)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (12)
  • GitHub Check: build
  • GitHub Check: check (29)
  • GitHub Check: check-rest
  • GitHub Check: check (24)
  • GitHub Check: check (21)
  • GitHub Check: check (29)
  • GitHub Check: check (19)
  • GitHub Check: check-liveobjects
  • GitHub Check: check
  • GitHub Check: check-realtime-okhttp
  • GitHub Check: check-realtime
  • GitHub Check: check-rest-okhttp
🔇 Additional comments (5)
lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java (5)

129-135: Good guard to enforce single connect call.


383-389: LGTM on fine-grained monitor usage for the activity timer.

This avoids broad transport locking and reduces deadlock risk.


52-56: Make shared refs volatile for correct cross-thread visibility (client/handler/connectListener).

These fields are written in connect() and read in handler/close threads. Without volatile, other threads may see stale nulls or older instances.

-    private ConnectListener connectListener;
-    private WebSocketClient webSocketClient;
+    private volatile ConnectListener connectListener;
+    private volatile WebSocketClient webSocketClient;
     private final WebSocketEngine webSocketEngine;
-    private WebSocketHandler webSocketHandler;
+    private volatile WebSocketHandler webSocketHandler;

Optionally also make the test switch volatile:

-    private boolean activityCheckTurnedOff = false;
+    private volatile boolean activityCheckTurnedOff = false;

140-159: Also dispose handler when client is null to avoid stray timer thread.

         } else {
             Log.w(TAG, "close() called on uninitialized or already closed transport");
+            if (handler != null) {
+                handler.dispose();
+            }
         }

392-404: Avoid NPE when canceling from timer catch; catch Throwable.

onClose may null or dispose client while the task runs. Snapshot and null-check.

-                    } catch (Exception exception) {
-                        Log.e(TAG, "Unexpected exception in activity timer handler", exception);
-                        webSocketClient.cancel(ABNORMAL_CLOSE, "Activity timer closed unexpectedly");
+                    } catch (Throwable t) {
+                        Log.e(TAG, "Unexpected exception in activity timer handler", t);
+                        final WebSocketClient c = webSocketClient;
+                        if (c != null) {
+                            c.cancel(ABNORMAL_CLOSE, "Activity timer closed unexpectedly");
+                        }
                     }

@github-actions github-actions bot temporarily deployed to staging/pull/1165/features September 25, 2025 13:57 Inactive
@github-actions github-actions bot temporarily deployed to staging/pull/1165/javadoc September 25, 2025 14:00 Inactive
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Jira integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 7d8e7b9 and 24ae90e.

📒 Files selected for processing (4)
  • lib/src/main/java/io/ably/lib/transport/ConnectionManager.java (2 hunks)
  • lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java (7 hunks)
  • lib/src/test/java/io/ably/lib/test/common/Helpers.java (1 hunks)
  • lib/src/test/java/io/ably/lib/transport/WebSocketTransportTest.java (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java
🧰 Additional context used
🧬 Code graph analysis (3)
lib/src/main/java/io/ably/lib/transport/ConnectionManager.java (1)
lib/src/main/java/io/ably/lib/types/Param.java (1)
  • Param (6-88)
lib/src/test/java/io/ably/lib/transport/WebSocketTransportTest.java (4)
lib/src/test/java/io/ably/lib/test/common/Helpers.java (1)
  • Helpers (74-1229)
lib/src/test/java/io/ably/lib/test/util/EmptyPlatformAgentProvider.java (1)
  • EmptyPlatformAgentProvider (5-10)
lib/src/main/java/io/ably/lib/transport/ITransport.java (1)
  • TransportParams (37-92)
lib/src/main/java/io/ably/lib/types/Param.java (1)
  • Param (6-88)
lib/src/test/java/io/ably/lib/test/common/Helpers.java (1)
liveobjects/src/test/kotlin/io/ably/lib/objects/TestUtils.kt (1)
  • setPrivateField (22-26)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (12)
  • GitHub Check: check-liveobjects
  • GitHub Check: check-realtime-okhttp
  • GitHub Check: build
  • GitHub Check: check
  • GitHub Check: check-realtime
  • GitHub Check: check-rest-okhttp
  • GitHub Check: check-rest
  • GitHub Check: check (21)
  • GitHub Check: check (24)
  • GitHub Check: check (29)
  • GitHub Check: check (19)
  • GitHub Check: check (29)
🔇 Additional comments (1)
lib/src/main/java/io/ably/lib/transport/ConnectionManager.java (1)

861-866: Expose cached auth query params to transports.

Handy passthrough to ably.auth.getAuthParams() so transports can reuse the connection manager’s current credentials. Nothing else about state management changes here, so no concerns.

…nagement

* 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`.
@ttypic ttypic force-pushed the ECO-5514/fix-websocket-close branch from 24ae90e to 620fdf0 Compare September 25, 2025 14:46
@github-actions github-actions bot temporarily deployed to staging/pull/1165/features September 25, 2025 14:47 Inactive
@github-actions github-actions bot temporarily deployed to staging/pull/1165/javadoc September 25, 2025 14:49 Inactive
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java (2)

100-128: Dispose handler on connect() failure to avoid Timer thread leaks

If create(...) or connect() throws after the handler is created, its Timer thread survives. Dispose it in the catch blocks.

         } catch (AblyException e) {
             Log.e(TAG, "Unexpected exception attempting connection; wsUri = " + wsUri, e);
             connectListener.onTransportUnavailable(this, e.errorInfo);
+            if (webSocketHandler != null) {
+                webSocketHandler.dispose();
+            }
         } catch (Throwable t) {
             Log.e(TAG, "Unexpected exception attempting connection; wsUri = " + wsUri, t);
             connectListener.onTransportUnavailable(this, AblyException.fromThrowable(t).errorInfo);
+            if (webSocketHandler != null) {
+                webSocketHandler.dispose();
+            }
         }

422-430: Also null-check client on timeout cancellation

Avoid NPE in rare paths where handler exists but client creation failed.

-                Log.e(TAG, "No activity for " + getActivityTimeout() + "ms, closing connection");
-                webSocketClient.cancel(ABNORMAL_CLOSE, "timed out");
+                Log.e(TAG, "No activity for " + getActivityTimeout() + "ms, closing connection");
+                final WebSocketClient c = webSocketClient;
+                if (c != null) {
+                    c.cancel(ABNORMAL_CLOSE, "timed out");
+                }
                 return;
🧹 Nitpick comments (2)
lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java (2)

52-53: Mark connectListener volatile for safe publication to WS callback threads

WS events may fire on different threads immediately after connect(); volatile ensures visibility.

-    private ConnectListener connectListener;
+    private volatile ConnectListener connectListener;

168-195: Snapshot client reference in send() to avoid races with close()

Use a local final reference and fail fast if null; route through existing NotConnectedException path.

     public void send(ProtocolMessage msg) throws AblyException {
         Log.d(TAG, "send(); action = " + msg.action);
         try {
+            final WebSocketClient c = this.webSocketClient;
+            if (c == null) {
+                throw new NotConnectedException();
+            }
             if (channelBinaryMode) {
                 byte[] encodedMsg = ProtocolSerializer.writeMsgpack(msg);
                 // Check the logging level to avoid performance hit associated with building the message
                 if (Log.level <= Log.VERBOSE) {
                     ProtocolMessage decodedMsg = ProtocolSerializer.readMsgpack(encodedMsg);
                     Log.v(TAG, "send(): " + decodedMsg.action + ": " + new String(ProtocolSerializer.writeJSON(decodedMsg)));
                 }
-                webSocketClient.send(encodedMsg);
+                c.send(encodedMsg);
             } else {
                 // Check the logging level to avoid performance hit associated with building the message
                 if (Log.level <= Log.VERBOSE)
                     Log.v(TAG, "send(): " + new String(ProtocolSerializer.writeJSON(msg)));
-                webSocketClient.send(ProtocolSerializer.writeJSON(msg));
+                c.send(ProtocolSerializer.writeJSON(msg));
             }
         } catch (NotConnectedException e) {
             if (connectListener != null) {
                 connectListener.onTransportUnavailable(this, AblyException.fromThrowable(e).errorInfo);
             } else
                 throw AblyException.fromThrowable(e);
         } catch (Exception e) {
             throw AblyException.fromThrowable(e);
         }
     }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Jira integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 24ae90e and 620fdf0.

📒 Files selected for processing (4)
  • lib/src/main/java/io/ably/lib/transport/ConnectionManager.java (2 hunks)
  • lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java (8 hunks)
  • lib/src/test/java/io/ably/lib/test/common/Helpers.java (1 hunks)
  • lib/src/test/java/io/ably/lib/transport/WebSocketTransportTest.java (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • lib/src/test/java/io/ably/lib/transport/WebSocketTransportTest.java
  • lib/src/main/java/io/ably/lib/transport/ConnectionManager.java
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-09-25T12:18:35.402Z
Learnt from: ttypic
PR: ably/ably-java#1165
File: lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java:250-251
Timestamp: 2025-09-25T12:18:35.402Z
Learning: In lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java, the lastActivityTime field is accessed from multiple threads (WebSocket callback threads and Timer thread) without synchronization, requiring volatile for proper visibility guarantees.

Applied to files:

  • lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java
🧬 Code graph analysis (1)
lib/src/test/java/io/ably/lib/test/common/Helpers.java (1)
liveobjects/src/test/kotlin/io/ably/lib/objects/TestUtils.kt (1)
  • setPrivateField (22-26)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (12)
  • GitHub Check: check (24)
  • GitHub Check: check (29)
  • GitHub Check: check (21)
  • GitHub Check: check (19)
  • GitHub Check: check-rest
  • GitHub Check: check-liveobjects
  • GitHub Check: check-rest-okhttp
  • GitHub Check: check-realtime
  • GitHub Check: check-realtime-okhttp
  • GitHub Check: check
  • GitHub Check: build
  • GitHub Check: check (29)
🔇 Additional comments (10)
lib/src/test/java/io/ably/lib/test/common/Helpers.java (1)

972-980: Extend reflection helper to walk superclasses to avoid failures when field is inherited

Current implementation fails for fields declared on a superclass. Walk the class hierarchy before giving up.

-    public static void setPrivateField(Object object, String fieldName, Object value) {
-        try {
-            Field connectionStateField = object.getClass().getDeclaredField(fieldName);
-            connectionStateField.setAccessible(true);
-            connectionStateField.set(object, value);
-        } catch (Exception e) {
-            fail("Failed accessing " + fieldName + " with error " + e);
-        }
-    }
+    public static void setPrivateField(Object object, String fieldName, Object value) {
+        for (Class<?> type = object.getClass(); type != null; type = type.getSuperclass()) {
+            try {
+                Field field = type.getDeclaredField(fieldName);
+                field.setAccessible(true);
+                field.set(object, value);
+                return;
+            } catch (NoSuchFieldException ignored) {
+                // keep walking
+            } catch (Exception e) {
+                fail("Failed accessing " + fieldName + " with error " + e);
+                return;
+            }
+        }
+        fail("Field \"" + fieldName + "\" not found on " + object.getClass());
+    }
lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java (9)

53-56: Make shared refs volatile to restore memory-visibility guarantees

-    private WebSocketClient webSocketClient;
+    private volatile WebSocketClient webSocketClient;
     private final WebSocketEngine webSocketEngine;
-    private WebSocketHandler webSocketHandler;
+    private volatile WebSocketHandler webSocketHandler;

130-137: Good guard to enforce single connect call

Synchronized one-time guard prevents lifecycle misuse and leaks.


141-160: Also dispose handler when client is null to avoid stray Timer threads

If client is null but handler exists, cancel the handler’s Timer.

         } else {
             Log.w(TAG, "close() called on uninitialized or already closed transport");
+            if (handler != null) {
+                handler.dispose();
+            }
         }

224-227: Nice: explicit active-transport check helper

Encapsulating this via ConnectionManager avoids tight coupling in tests and production code.


254-254: Make activity Timer a daemon (and name it) to avoid pinning JVM if disposal is missed

Daemon timer reduces impact of missed cancel() and aids diagnostics.

-        private final Timer timer = new Timer();
+        private final Timer timer = new Timer("WebSocketActivity-" + System.identityHashCode(WebSocketTransport.this), true);

256-257: lastActivityTime needs volatile for visibility between WS callback threads and Timer thread

-        private long lastActivityTime;
+        private volatile long lastActivityTime;

360-362: LGTM: handler dispose is simple and idempotent

Canceling the Timer is sufficient; schedule() already tolerates canceled timers.


364-381: LGTM: activity gating only updates timestamp when active, but still arms timer

This keeps ConnectionManager metrics clean while still enabling force-cancel on close.


400-412: Null-deref risk when canceling client from TimerTask catch

webSocketClient can be nulled or not yet created; snapshot and null-check before canceling.

-                        webSocketClient.cancel(ABNORMAL_CLOSE, "Activity timer closed unexpectedly");
+                        final WebSocketClient c = webSocketClient;
+                        if (c != null) {
+                            c.cancel(ABNORMAL_CLOSE, "Activity timer closed unexpectedly");
+                        }

- Test edge cases like multiple `connect()` calls, forced closures, and redundant `onClose` events.
@ttypic ttypic force-pushed the ECO-5514/fix-websocket-close branch from 620fdf0 to a6f0f75 Compare September 25, 2025 15:25
@github-actions github-actions bot temporarily deployed to staging/pull/1165/features September 25, 2025 15:26 Inactive
@github-actions github-actions bot temporarily deployed to staging/pull/1165/javadoc September 25, 2025 15:28 Inactive
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/src/test/java/io/ably/lib/transport/WebSocketTransportTest.java (1)

1-154: Replace deprecated invocation.getArgumentAt calls
Use invocation.getArgument(index) instead of getArgumentAt(…) across tests:

  • lib/src/test/java/io/ably/lib/transport/WebSocketTransportTest.java (lines 83, 116, 140)
  • lib/src/test/java/io/ably/lib/http/HttpHelpersTest.java (lines 31, 52)
🧹 Nitpick comments (3)
lib/src/main/java/io/ably/lib/transport/ConnectionManager.java (1)

868-877: Decouple from concrete transport: accept ITransport instead of WebSocketTransport

Avoids unnecessary coupling from ConnectionManager to a concrete transport type.

Apply:

-    boolean isActiveTransport(WebSocketTransport transport) {
+    boolean isActiveTransport(ITransport transport) {
         return transport == this.transport;
     }
lib/src/test/java/io/ably/lib/transport/WebSocketTransportTest.java (1)

1-154: Optional: tighten matcher typing for ErrorInfo

To avoid raw any() ambiguity, prefer any(ErrorInfo.class) in verifications.

Example:

-        verify(connectListener).onTransportUnavailable(eq(transport), any());
+        verify(connectListener).onTransportUnavailable(eq(transport), any(ErrorInfo.class));
lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java (1)

256-256: Make lastActivityTime volatile for cross-thread visibility

Written by WS callback threads, read by Timer thread; volatile avoids stale reads.

-        private long lastActivityTime;
+        private volatile long lastActivityTime;
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Jira integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 620fdf0 and a6f0f75.

📒 Files selected for processing (4)
  • lib/src/main/java/io/ably/lib/transport/ConnectionManager.java (2 hunks)
  • lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java (8 hunks)
  • lib/src/test/java/io/ably/lib/test/common/Helpers.java (1 hunks)
  • lib/src/test/java/io/ably/lib/transport/WebSocketTransportTest.java (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • lib/src/test/java/io/ably/lib/test/common/Helpers.java
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-09-25T12:18:35.432Z
Learnt from: ttypic
PR: ably/ably-java#1165
File: lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java:250-251
Timestamp: 2025-09-25T12:18:35.432Z
Learning: In lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java, the lastActivityTime field is accessed from multiple threads (WebSocket callback threads and Timer thread) without synchronization, requiring volatile for proper visibility guarantees.

Applied to files:

  • lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java
🧬 Code graph analysis (2)
lib/src/main/java/io/ably/lib/transport/ConnectionManager.java (1)
lib/src/main/java/io/ably/lib/types/Param.java (1)
  • Param (6-88)
lib/src/test/java/io/ably/lib/transport/WebSocketTransportTest.java (5)
lib/src/test/java/io/ably/lib/test/common/Helpers.java (1)
  • Helpers (74-1229)
lib/src/test/java/io/ably/lib/test/util/EmptyPlatformAgentProvider.java (1)
  • EmptyPlatformAgentProvider (5-10)
lib/src/main/java/io/ably/lib/transport/ITransport.java (1)
  • TransportParams (37-92)
lib/src/main/java/io/ably/lib/types/ClientOptions.java (1)
  • ClientOptions (19-392)
lib/src/main/java/io/ably/lib/types/Param.java (1)
  • Param (6-88)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (12)
  • GitHub Check: build
  • GitHub Check: check-realtime
  • GitHub Check: check-rest-okhttp
  • GitHub Check: check-liveobjects
  • GitHub Check: check-realtime-okhttp
  • GitHub Check: check (24)
  • GitHub Check: check (21)
  • GitHub Check: check (29)
  • GitHub Check: check (19)
  • GitHub Check: check-rest
  • GitHub Check: check (29)
  • GitHub Check: check
🔇 Additional comments (13)
lib/src/main/java/io/ably/lib/transport/ConnectionManager.java (2)

34-34: LGTM: Param import

Import is required for getAuthParams(). No issues.


861-867: LGTM: add getAuthParams() passthrough

Package-private accessor is appropriate for transport usage and tests. Exception propagation is correct.

lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java (7)

100-128: LGTM: connect flow and single-call guard

Enforcing single connect, building wsUri with auth/connect params, and robust exception handling looks good.


133-136: LGTM: connect-once enforcement

Synchronized guard prevents leaks from double connect.


254-254: Make Timer daemon (or lazy-init) to reduce leak impact

Non-daemon Timer can keep the process alive if disposal is missed.

-        private final Timer timer = new Timer();
+        private final Timer timer = new Timer("WebSocketActivity-" + System.identityHashCode(WebSocketTransport.this), true);

400-412: Null-check client before cancel in TimerTask catch

webSocketClient may be null if close raced; prevent NPE.

-                        webSocketClient.cancel(ABNORMAL_CLOSE, "Activity timer closed unexpectedly");
+                        final WebSocketClient c = webSocketClient;
+                        if (c != null) {
+                            c.cancel(ABNORMAL_CLOSE, "Activity timer closed unexpectedly");
+                        }

364-381: LGTM: activity gating and timer arming

Only flags activity when this is the active transport; respects maxIdleInterval and test override.


383-399: LGTM: start timer once, let expiry reschedule

Minimizes contention and avoids frequent rescheduling.


141-161: Dispose handler when client is null to avoid Timer thread leak

If close() races before client assignment (handler already created), else branch logs but leaves handler Timer running. That Timer is non-daemon and can pin the JVM.

Apply:

         } else {
             Log.w(TAG, "close() called on uninitialized or already closed transport");
+            if (handler != null) {
+                handler.dispose();
+            }
         }
lib/src/test/java/io/ably/lib/transport/WebSocketTransportTest.java (4)

82-85: Replace removed Mockito API getArgumentAt with getArgument

Mockito 3+ removed getArgumentAt; current code won’t compile.

Apply:

-        when(mockEngine.create(any(), any())).thenAnswer(invocation -> {
-            webSocketListenerRef.set(invocation.getArgumentAt(1, WebSocketListener.class));
+        when(mockEngine.create(any(), any())).thenAnswer(invocation -> {
+            webSocketListenerRef.set(invocation.getArgument(1, WebSocketListener.class));
             return mockWebSocketClient;
         });

87-94: Ditto: use getArgument(...) to forward cancel args

Same compilation issue here.

-        doAnswer(invocation -> {
-            webSocketListenerRef.get().onClose(
-                    invocation.getArgumentAt(0, Integer.class),
-                    invocation.getArgumentAt(1, String.class)
-            );
-            return null;
-        }).when(mockWebSocketClient).cancel(anyInt(), anyString());
+        doAnswer(invocation -> {
+            webSocketListenerRef.get().onClose(
+                    invocation.getArgument(0, Integer.class),
+                    invocation.getArgument(1, String.class)
+            );
+            return null;
+        }).when(mockWebSocketClient).cancel(anyInt(), anyString());

115-118: Replace getArgumentAt in listener capture

Update to supported Mockito API.

-        when(mockEngine.create(any(), any())).thenAnswer(invocation -> {
-            listenerRef.set(invocation.getArgumentAt(1, WebSocketListener.class));
+        when(mockEngine.create(any(), any())).thenAnswer(invocation -> {
+            listenerRef.set(invocation.getArgument(1, WebSocketListener.class));
             return mockWebSocketClient;
         });

139-142: Last getArgumentAt occurrence

Update to getArgument here as well.

-        when(mockEngine.create(any(), any())).thenAnswer(invocation -> {
-            listenerRef.set(invocation.getArgumentAt(1, WebSocketListener.class));
+        when(mockEngine.create(any(), any())).thenAnswer(invocation -> {
+            listenerRef.set(invocation.getArgument(1, WebSocketListener.class));
             return mockWebSocketClient;
         });

@github-actions github-actions bot temporarily deployed to staging/pull/1165/features September 26, 2025 08:58 Inactive
@github-actions github-actions bot temporarily deployed to staging/pull/1165/javadoc September 26, 2025 09:00 Inactive
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java (1)

431-433: Guard against races when force-canceling on timeout

Same race applies here; null-check before calling cancel(...) to avoid Timer thread exceptions.

-                webSocketClient.cancel(ABNORMAL_CLOSE, "timed out");
+                final WebSocketClient c = webSocketClient;
+                if (c != null) {
+                    c.cancel(ABNORMAL_CLOSE, "timed out");
+                }
🧹 Nitpick comments (1)
lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java (1)

364-381: Prefer monotonic clock for elapsed-time checks

Using System.currentTimeMillis() for elapsed time can be skewed by clock adjustments. Consider switching to System.nanoTime() for computing inactivity intervals to avoid false positives/negatives.

Also applies to: 425-431

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Jira integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between a6f0f75 and 718573c.

📒 Files selected for processing (1)
  • lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java (8 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-09-25T12:18:35.432Z
Learnt from: ttypic
PR: ably/ably-java#1165
File: lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java:250-251
Timestamp: 2025-09-25T12:18:35.432Z
Learning: In lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java, the lastActivityTime field is accessed from multiple threads (WebSocket callback threads and Timer thread) without synchronization, requiring volatile for proper visibility guarantees.

Applied to files:

  • lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java
🧬 Code graph analysis (1)
lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java (2)
lib/src/main/java/io/ably/lib/http/HttpUtils.java (1)
  • HttpUtils (28-280)
lib/src/main/java/io/ably/lib/util/Log.java (1)
  • Log (21-157)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (12)
  • GitHub Check: check (29)
  • GitHub Check: check (24)
  • GitHub Check: check (21)
  • GitHub Check: check (19)
  • GitHub Check: check-liveobjects
  • GitHub Check: check-rest-okhttp
  • GitHub Check: check-realtime-okhttp
  • GitHub Check: check-realtime
  • GitHub Check: check-rest
  • GitHub Check: check (29)
  • GitHub Check: check
  • GitHub Check: build
🔇 Additional comments (7)
lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java (7)

255-257: Good: volatile fields restore cross-thread visibility

Marking activityTimerTask and lastActivityTime as volatile ensures visibility between WS callback threads and the Timer thread. This aligns with concurrency best practices.

Based on learnings


133-136: Single-connect guard looks good

The synchronized guard is simple and effective to prevent multiple connect calls.


224-226: Helper to check active transport is a good addition

This improves coordination with ConnectionManager and activity logic.


444-446: Confirm activity-timeout source change is intentional

Using params.options.realtimeRequestTimeout (instead of a manager-level setting) modifies the effective timeout. Please confirm this matches spec/intent for reconnection vs idle detection timing.


409-411: Avoid NPE when canceling from TimerTask catch block

webSocketClient can be concurrently null or not yet initialized when the catch runs; dereferencing it risks NPE. Snapshot or null-check first.

-                        webSocketClient.cancel(ABNORMAL_CLOSE, "Activity timer closed unexpectedly");
+                        final WebSocketClient c = webSocketClient;
+                        if (c != null) {
+                            c.cancel(ABNORMAL_CLOSE, "Activity timer closed unexpectedly");
+                        }

254-254: Make the Timer a daemon (and name it) to avoid pinning the JVM

new Timer() creates a non-daemon thread. If disposal is missed in any edge case, it can keep the JVM alive. Use a daemon timer and give it a helpful name.

-        private final Timer timer = new Timer();
+        private final Timer timer = new Timer("WebSocketActivity-" + System.identityHashCode(WebSocketTransport.this), true);

151-160: Dispose handler when client is null to avoid Timer thread leak

If webSocketClient == null but webSocketHandler != null, close() only logs and leaves the handler’s Timer thread running. This leaks a non-daemon thread and undermines the PR’s objective to prevent leaks.

Apply:

         } else {
             Log.w(TAG, "close() called on uninitialized or already closed transport");
+            if (handler != null) {
+                handler.dispose();
+            }
         }

…tTransport

- marked `lastActivityTime` as volatile, it's low cost, but it will ensure that the timer reads fresh value, otherwise it can read very old value (although it's very unlikely)

- check activity timer before going to the synchronized block
Copy link
Collaborator

@sacOO7 sacOO7 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lgtm

@ttypic ttypic merged commit 19c2898 into main Sep 26, 2025
14 of 15 checks passed
@ttypic ttypic deleted the ECO-5514/fix-websocket-close branch September 26, 2025 15:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

Thread leak if there's no internet connection

3 participants