Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
342 changes: 250 additions & 92 deletions Runtime/codebase/SolanaMobileStack/LocalAssociationScenario.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using NativeWebSocket;
using Newtonsoft.Json;
Expand All @@ -11,137 +12,294 @@

// ReSharper disable once CheckNamespace

public class LocalAssociationScenario
public class LocalAssociationScenario : IDisposable
{
private readonly TimeSpan _clientTimeoutMs;
private readonly MobileWalletAdapterSession _session;
private readonly int _port;
private readonly IWebSocket _webSocket;
private AndroidJavaObject _nativeLocalAssociationScenario;
private TaskCompletionSource<Response<object>> _startAssociationTaskCompletionSource;
private readonly TimeSpan _overallTimeout = TimeSpan.FromSeconds(30);
private readonly TimeSpan _keyExchangeTimeout = TimeSpan.FromSeconds(20);

private bool _didConnect;
private bool _handledEncryptedMessage;
private MobileWalletAdapterClient _client;
private readonly AndroidJavaObject _currentActivity;
private Queue<Action<IAdapterOperations>> _actions;
private readonly int _port;
private readonly MobileWalletAdapterSession _session;
private IWebSocket _webSocket;
private MobileWalletAdapterClient _client;

private bool _isConnecting;
private bool _disposed;

public LocalAssociationScenario(int clientTimeoutMs = 9000)
private TaskCompletionSource<bool> _wsConnected;
private TaskCompletionSource<Response<object>> _responseTcs;
private TaskCompletionSource<Response<object>> _tcs;
private CancellationToken _cancellationToken;

public LocalAssociationScenario()
{
var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer");
_currentActivity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity");
_clientTimeoutMs = TimeSpan.FromSeconds(clientTimeoutMs);
_port = Random.Range(WebSocketsTransportContract.WebsocketsLocalPortMin, WebSocketsTransportContract.WebsocketsLocalPortMax + 1);
_currentActivity = GetCurrentActivity();
_port = RandomPort();
_session = new MobileWalletAdapterSession();
var webSocketUri = WebSocketsTransportContract.WebsocketsLocalScheme + "://" + WebSocketsTransportContract.WebsocketsLocalHost + ":" + _port + WebSocketsTransportContract.WebsocketsLocalPath;
_webSocket = WebSocket.Create(webSocketUri, WebSocketsTransportContract.WebsocketsProtocol);
_webSocket.OnOpen += () =>
{
if(_didConnect)return;
_didConnect = true;
var helloReq = _session.CreateHelloReq();
_webSocket.Send(helloReq);
ListenKeyExchange();
};
_webSocket.OnClose += (e) =>
{
if (!_didConnect) return;
_webSocket.Connect(awaitConnection: false);
};
_webSocket.OnError += (e) =>
{
Debug.Log("WebSocket Error: " + e);
};
_webSocket.OnMessage += ReceivePublicKeyHandler;
}

private static AndroidJavaObject GetCurrentActivity()
{
var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer");
return unityPlayer.GetStatic<AndroidJavaObject>("currentActivity");
}

public Task<Response<object>> StartAndExecute(List<Action<IAdapterOperations>> actions)
public async Task<Response<object>> StartAndExecute(List<Action<IAdapterOperations>> actions,
CancellationToken ct = default)
{
if (actions == null || actions.Count == 0)
throw new ArgumentException("Actions must be non-null and non-empty");
_actions = new Queue<Action<IAdapterOperations>>(actions);
var intent = LocalAssociationIntentCreator.CreateAssociationIntent(
_session.AssociationToken,
_port);
throw new ArgumentException("Actions required");

using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(_overallTimeout);

_cancellationToken = ct;
_tcs = new TaskCompletionSource<Response<object>>();
Comment on lines +46 to +50
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Overall timeout isn’t enforced across the connection/action flow.

You create a linked CTS with CancelAfter, but _cancellationToken and ThrowIfCancellationRequested use the original ct, so the 30s timeout won’t cancel ConnectWithBackoffAsync or the action loop when no external token is provided.

✅ Minimal fix to honor the overall timeout
-        _cancellationToken = ct;
+        _cancellationToken = cts.Token;
         _tcs = new TaskCompletionSource<Response<object>>();
@@
-                    ct.ThrowIfCancellationRequested();
+                    _cancellationToken.ThrowIfCancellationRequested();

Also applies to: 94-97

🤖 Prompt for AI Agents
In `@Runtime/codebase/SolanaMobileStack/LocalAssociationScenario.cs` around lines
53 - 57, You created a linked CancellationTokenSource with
CancelAfter(_overallTimeout) but still assign and use the original ct, so the
overall timeout never cancels ConnectWithBackoffAsync or the action loop; change
the assignment to use the linked CTS token (set _cancellationToken = cts.Token)
and ensure all cancellation checks and ThrowIfCancellationRequested() calls use
_cancellationToken (and not the original ct); apply the same fix to the other
linked-CTS block (the one around lines with CreateLinkedTokenSource/CancelAfter
at the second site) so the overall timeout is honored across
ConnectWithBackoffAsync and the action loop.


StartActivityForAssociation(_session.AssociationToken, _port);

Debug.Log("[MWA] Waiting for websocket connection");
await Task.Run(async () =>
{
try
{
Debug.Log("[MWA Connect Thread] Started");
_isConnecting = true;

await ConnectWithBackoffAsync();

Debug.Log("[MWA Connect Thread] Completed");
_isConnecting = false;

var helloReq = _session.CreateHelloReq();
await _webSocket.Send(helloReq);

Debug.Log("[MWA] Hello sent. Waiting for pubkey...");

await WaitForKeyExchangeAsync(cts.Token);

Debug.Log("[MWA] Pubkey received, session is encrypted");

var queue = new Queue<Action<IAdapterOperations>>(actions);
Response<object> lastResponse = null;

while (queue.Count > 0)
{
_responseTcs = new TaskCompletionSource<Response<object>>();

var action = queue.Dequeue();
Debug.Log($"[MWA] Invoking action {action.Method.Name}");
action.Invoke(_client);

lastResponse = await _responseTcs.Task;

ct.ThrowIfCancellationRequested();
}

_tcs.TrySetResult(lastResponse ?? new Response<object>());
}
catch (OperationCanceledException)
{
_tcs.TrySetResult(new Response<object>
{
Error = new Response<object>.ResponseError { Message = "Timeout or cancelled" }
});
}
catch (Exception ex)
{
Debug.Log($"[MWA] Association failed: {ex}");
_tcs.TrySetException(ex);
}
finally
{
await CleanupAsync();
}
}, cts.Token);

return await _tcs.Task;
}
Comment on lines +40 to +114
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

List<Action<IAdapterOperations>> still enables async-void exception swallowing — the core rewrite goal is only half-done.

The PR description calls out "cleaner error handling", but the delegate remains Action<IAdapterOperations> and is invoked fire-and-forget at Line 92 (action.Invoke(_client);). All three call sites in SolanaMobileWalletAdapter pass async client => { … } lambdas, which bind to Action as async void: any exception thrown inside (e.g., a JSON-RPC failure, a Deauthorize error, a cancellation) escapes this scenario entirely instead of faulting _responseTcs / _tcs. The loop then blocks on _responseTcs.Task until the overall timeout, masking the real error.

Migrating to Func<IAdapterOperations, Task> and awaiting each action lets exceptions propagate into the surrounding try/catch and turn into _tcs.TrySetException, which is what the new cancellation/timeout plumbing is meant to rely on.

♻️ Suggested signature change
-    public async Task<Response<object>> StartAndExecute(List<Action<IAdapterOperations>> actions,
-        CancellationToken ct = default)
+    public async Task<Response<object>> StartAndExecute(
+        IReadOnlyList<Func<IAdapterOperations, Task>> actions,
+        CancellationToken ct = default)
@@
-                    var action = queue.Dequeue();
-                    Debug.Log($"[MWA] Invoking action {action.Method.Name}");
-                    action.Invoke(_client);
-                
-                    lastResponse = await _responseTcs.Task;
+                    var action = queue.Dequeue();
+                    Debug.Log($"[MWA] Invoking action {action.Method.Name}");
+                    await action(_client).ConfigureAwait(false);
+
+                    lastResponse = await _responseTcs.Task;

Callers in SolanaMobileWalletAdapter._Login / _SignAllTransactions / SignMessage then drop the async client => …Action cast and pass the lambda directly.

Based on learnings: prior PR #269 intentionally left the async client => { … } lambdas as Action<IAdapterOperations> (async void) because PR #260 was expected to migrate the delegate type to Func<IAdapterOperations, Task> as part of the ExecuteNextAction rewrite; leaving it as Action here keeps the exception-swallowing behavior the learning flagged.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Runtime/codebase/SolanaMobileStack/LocalAssociationScenario.cs` around lines
47 - 121, Change the actions delegate from synchronous
Action<IAdapterOperations> to an async-aware Func<IAdapterOperations, Task> so
exceptions aren't swallowed: update the StartAndExecute signature to accept
List<Func<IAdapterOperations, Task>>, change where each action is invoked in the
loop (currently action.Invoke(_client) in StartAndExecute) to await
action(_client), and update all call sites in SolanaMobileWalletAdapter (_Login,
_SignAllTransactions, SignMessage) to pass async lambdas directly (remove any
cast to Action). Ensure _responseTcs/_tcs behavior remains the same so thrown
exceptions propagate into the surrounding try/catch and fault the
TaskCompletionSources.


private static int RandomPort()
{
return Random.Range(WebSocketsTransportContract.WebsocketsLocalPortMin,
WebSocketsTransportContract.WebsocketsLocalPortMax + 1);
}

private static IWebSocket CreateWebSocket(int port)
{
var webSocketUri = WebSocketsTransportContract.WebsocketsLocalScheme + "://" +
WebSocketsTransportContract.WebsocketsLocalHost + ":" + port +
WebSocketsTransportContract.WebsocketsLocalPath;

Debug.Log($"[MWA] Websocket created with URI {webSocketUri}");
return WebSocket.Create(webSocketUri, WebSocketsTransportContract.WebsocketsProtocol);
}

private void StartActivityForAssociation(string associationToken, int port)
{
var intent = LocalAssociationIntentCreator.CreateAssociationIntent(associationToken, port);
_currentActivity.Call("startActivityForResult", intent, 0);
_currentActivity.Call("runOnUiThread", new AndroidJavaRunnable(TryConnectWs));
_startAssociationTaskCompletionSource = new TaskCompletionSource<Response<object>>();
return _startAssociationTaskCompletionSource.Task;
Debug.Log($"[MWA] Launched intent for port {port}, token {associationToken}");
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
private async void TryConnectWs()

private async Task ConnectWithBackoffAsync()
{
var timeout = _clientTimeoutMs;
while (_webSocket.State != WebSocketState.Open && !_didConnect && timeout.TotalSeconds > 0)
{
await _webSocket.Connect(awaitConnection: false);
var timeDelta = TimeSpan.FromMilliseconds(500);
timeout -= timeDelta;
await Task.Delay(timeDelta);
}
if (_webSocket.State != WebSocketState.Open)
const int maxAttempts = 12;
const int delayStart = 400;
const int delayCap = 3000;

var attempt = 0;
var delayMs = delayStart;

// Short delay to give wallet time to start websocket
Debug.Log($"[MWA] Start delay");
await Task.Delay(500, _cancellationToken);
Debug.Log($"[MWA] Delay over");

do
{
Debug.Log("Error: timeout");
}
if (_webSocket != null)
{
_webSocket.OnOpen -= OnWsOpen;
_webSocket.OnError -= OnWsError;
_webSocket.OnClose -= OnWsClose;
_webSocket.OnMessage -= OnWsMessage;
_webSocket = null;
}

_webSocket = CreateWebSocket(_port);
_webSocket.OnOpen += OnWsOpen;
_webSocket.OnError += OnWsError;
_webSocket.OnClose += OnWsClose;
_webSocket.OnMessage += OnWsMessage;

var startTime = DateTime.UtcNow;
_wsConnected = new TaskCompletionSource<bool>();

attempt++;
Debug.Log($"[MWA] Connect attempt {attempt}, state: {_webSocket.State}");
_webSocket.Connect();

var success = await _wsConnected.Task;
Debug.Log($"[MWA] Connect attempt {attempt} result, state: {_webSocket.State}");
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (success)
return;

var duration = (int)(DateTime.UtcNow - startTime).TotalMilliseconds;
if (duration < delayMs)
{
await Task.Delay(delayMs - duration, _cancellationToken);
}

delayMs = Math.Min(delayMs * 2, delayCap);

} while (_webSocket.State != WebSocketState.Open && !_cancellationToken.IsCancellationRequested &&
attempt < maxAttempts);

throw new TimeoutException("WebSocket connect timed out after max attempts");
}

private async void ListenKeyExchange()
private void OnWsOpen()
{
while (!_handledEncryptedMessage)
Debug.Log("[MWA] WS Opened");

if (_isConnecting)
{
var timeDelta = TimeSpan.FromMilliseconds(300);
await Task.Delay(timeDelta);
_wsConnected.TrySetResult(true);
}
}

private void HandleEncryptedSessionPayload(byte[] e)
private void OnWsClose(WebSocketCloseCode closeCode)
{
if (!_didConnect)
Debug.Log($"[MWA] WS Closed: {closeCode}");
if (closeCode == WebSocketCloseCode.Normal)
return;

if (!_isConnecting)
{
throw new InvalidOperationException("Invalid message received; terminating session");
_tcs?.TrySetException(new Exception($"WS closed unexpectedly: {closeCode}"));
}
else
{
_wsConnected.TrySetResult(false);
}

var de = _session.DecryptSessionPayload(e);
var message = System.Text.Encoding.UTF8.GetString(de);
_client.Receive(message);
var receivedResponse = JsonConvert.DeserializeObject<Response<object>>(message);;
ExecuteNextAction(receivedResponse);
}

private static void OnWsError(string message)
{
Debug.Log($"[MWA] WS Error: {message}");
}

private void ReceivePublicKeyHandler(byte[] m)
private void OnWsMessage(byte[] bytes)
{
try
{
_session.GenerateSessionEcdhSecret(m);
var messageSender = new MobileWalletAdapterWebSocket(_webSocket, _session);
_client = new MobileWalletAdapterClient(messageSender);
_webSocket.OnMessage -= ReceivePublicKeyHandler;
_webSocket.OnMessage += HandleEncryptedSessionPayload;

// Executing the first action
ExecuteNextAction();
// First message expected: raw pubkey for ECDH
if (_client == null)
{
_session.GenerateSessionEcdhSecret(bytes);
var messageSender = new MobileWalletAdapterWebSocket(_webSocket, _session);
_client = new MobileWalletAdapterClient(messageSender);

Debug.Log("[MWA] Key exchange complete → encrypted session ready");
}
// All other should be encrypted messages
else
{
var decrypted = _session.DecryptSessionPayload(bytes);
var json = System.Text.Encoding.UTF8.GetString(decrypted);
_client.Receive(json);

Debug.Log($"[MWA] Received: {json}");

var response = JsonConvert.DeserializeObject<Response<object>>(json);
_responseTcs.TrySetResult(response);
}
}
catch (Exception e)
catch (Exception ex)
{
Console.WriteLine(e);
Debug.Log($"[MWA] Message handler error: {ex}");
_responseTcs.TrySetException(ex);
}
}
Comment on lines +233 to 264
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

_responseTcs is null during key exchange — exception path NREs and silently hangs.

At the point OnWsMessage first fires (_client == null, Lines 243-249), _responseTcs has not been created yet — the action loop only allocates it at Line 88 after WaitForKeyExchangeAsync returns. If GenerateSessionEcdhSecret / the client construction throws, the catch at Line 266 calls _responseTcs.TrySetException(ex) and raises a NullReferenceException from inside the websocket event callback. The real failure is swallowed, WaitForKeyExchangeAsync keeps polling until its 20s timeout, and the caller sees a generic timeout instead of the underlying crypto/parse error.

Line 223 already uses _responseTcs?.TrySet… — the handlers below should be symmetric, and the pre-_client branch should fail fast via _tcs/_wsConnected instead.

🐛 Route failures to the active waiter
-        catch (Exception ex)
-        {
-            Debug.Log($"[MWA] Message handler error: {ex}");
-            _responseTcs.TrySetException(ex);
-        }
+        catch (Exception ex)
+        {
+            Debug.Log($"[MWA] Message handler error: {ex}");
+            if (_client == null)
+            {
+                // Key-exchange phase: fail the connect waiter and the outer scenario.
+                _wsConnected?.TrySetException(ex);
+                _tcs?.TrySetException(ex);
+            }
+            else
+            {
+                _responseTcs?.TrySetException(ex);
+            }
+        }

Consider the same ?. guard on Line 260 for stray messages arriving between actions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Runtime/codebase/SolanaMobileStack/LocalAssociationScenario.cs` around lines
237 - 268, The OnWsMessage handler can throw before _responseTcs is created,
causing an NRE in the catch and masking the real error; change all
TrySetException/ TrySetResult calls to use null-conditional (e.g.
_responseTcs?.TrySetException(...), _responseTcs?.TrySetResult(...)) and, in the
pre-_client branch (inside the _client == null block where
GenerateSessionEcdhSecret and MobileWalletAdapterClient are invoked), on error
route the failure to the active waiter by calling _tcs?.TrySetException(ex) or
setting _wsConnected appropriately so the key-exchange waiter is notified; also
apply the same null-conditional guard for stray messages arriving between
actions when you call _responseTcs.TrySetResult/TrySetException.


private void ExecuteNextAction(Response<object> response = null)
private Task WaitForKeyExchangeAsync(CancellationToken ct)
{
if (_actions.Count == 0 || response is { Failed: true })
CloseAssociation(response);
var action = _actions.Dequeue();
action.Invoke(_client);
return Task.Run(async () =>
{
var start = DateTime.UtcNow;
while (_client == null)
{
if (ct.IsCancellationRequested || DateTime.UtcNow - start > _keyExchangeTimeout)
throw new TimeoutException("Key exchange timed out");

await Task.Delay(200, ct);
}
}, ct);
}
Comment on lines +266 to +279
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Prefer a TCS signal over 200 ms polling for key exchange.

WaitForKeyExchangeAsync busy-polls _client != null every 200 ms; OnWsMessage already knows the exact instant the pubkey is processed and could complete a dedicated TaskCompletionSource<bool> instead. That also gives you a natural place to TrySetException on key-exchange failure (related to the NRE issue raised above), and avoids the 0–200 ms latency added per handshake.

♻️ Signal-based wait
-    private Task WaitForKeyExchangeAsync(CancellationToken ct)
-    {
-        return Task.Run(async () =>
-        {
-            var start = DateTime.UtcNow;
-            while (_client == null)
-            {
-                if (ct.IsCancellationRequested || DateTime.UtcNow - start > _keyExchangeTimeout)
-                    throw new TimeoutException("Key exchange timed out");
-
-                await Task.Delay(200, ct);
-            }
-        }, ct);
-    }
+    private readonly TaskCompletionSource<bool> _keyExchangeTcs = new();
+
+    private async Task WaitForKeyExchangeAsync(CancellationToken ct)
+    {
+        using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+        timeoutCts.CancelAfter(_keyExchangeTimeout);
+        using (timeoutCts.Token.Register(() => _keyExchangeTcs.TrySetException(
+                   new TimeoutException("Key exchange timed out"))))
+        {
+            await _keyExchangeTcs.Task;
+        }
+    }

And in OnWsMessage's _client == null branch, call _keyExchangeTcs.TrySetResult(true); after the client is constructed (or TrySetException in the catch).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Runtime/codebase/SolanaMobileStack/LocalAssociationScenario.cs` around lines
270 - 283, Replace the 200ms polling in WaitForKeyExchangeAsync with a
TaskCompletionSource-based signal: add a field _keyExchangeTcs
(TaskCompletionSource<bool>) that WaitForKeyExchangeAsync awaits with the
provided CancellationToken and _keyExchangeTimeout, and remove the Task.Run
loop; in OnWsMessage, when the branch that constructs _client completes
successfully call _keyExchangeTcs.TrySetResult(true) (and call TrySetException
on failures), ensuring you create/reset _keyExchangeTcs before expecting a key
exchange and handle already-completed/timeout cases so WaitForKeyExchangeAsync
throws TimeoutException when the timeout elapses using _keyExchangeTimeout and
honors ct.


private async Task CleanupAsync()
{
if (_webSocket is { State: WebSocketState.Open })
await _webSocket.Close();

if (_webSocket != null)
{
_webSocket.OnOpen -= OnWsOpen;
_webSocket.OnMessage -= OnWsMessage;
_webSocket.OnError -= OnWsError;
_webSocket.OnClose -= OnWsClose;
_webSocket = null;
}

_client = null;
_disposed = true;
}

private async void CloseAssociation(Response<object> response)
void IDisposable.Dispose()
{
_webSocket.OnMessage -= HandleEncryptedSessionPayload;
_handledEncryptedMessage = true;
await _webSocket.Close();
_startAssociationTaskCompletionSource.SetResult(response);
if (_disposed) return;
_ = CleanupAsync();
}
Comment on lines +281 to 303
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Dispose() is fire-and-forget — using returns before the socket is actually closed, and any cleanup exception is swallowed.

_ = CleanupAsync(); at Line 306 completes Dispose synchronously while _webSocket.Close() is still in flight. For the using var localAssociationScenario = new LocalAssociationScenario() call sites in SolanaMobileWalletAdapter, that means the next _Login / SignMessage can create a fresh scenario while the previous socket is still draining on the wallet side (the exact class of bug this PR is trying to fix). In addition, because _disposed is only flipped at the end of CleanupAsync (Line 300), two rapid Dispose calls will both start concurrent cleanups that race on _webSocket = null.

Two viable fixes:

  1. Implement IAsyncDisposable and switch call sites to await using, which is the idiomatic pattern when cleanup is genuinely async.
  2. Make Dispose() synchronous by blocking on cleanup (e.g., CleanupAsync().GetAwaiter().GetResult()), guarded by setting _disposed = true up-front to make it idempotent.
♻️ Option 1: IAsyncDisposable
-public class LocalAssociationScenario : IDisposable
+public class LocalAssociationScenario : IAsyncDisposable
@@
-    void IDisposable.Dispose()
-    {
-        if (_disposed) return;
-        _ = CleanupAsync();
-    }
+    public async ValueTask DisposeAsync()
+    {
+        if (_disposed) return;
+        _disposed = true;
+        await CleanupAsync();
+    }

Then at each call site: await using var localAssociationScenario = new LocalAssociationScenario();.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Runtime/codebase/SolanaMobileStack/LocalAssociationScenario.cs` around lines
285 - 307, Dispose is currently fire-and-for-get because IDisposable.Dispose
calls CleanupAsync() without awaiting, causing races on _webSocket and
_disposed; change the class to support true async disposal by implementing
IAsyncDisposable and moving the cleanup logic into ValueTask
IAsyncDisposable.DisposeAsync that awaits CleanupAsync(), update
IDisposable.Dispose to either call
DisposeAsync().AsTask().GetAwaiter().GetResult() or set _disposed true and
synchronously block on CleanupAsync().GetAwaiter().GetResult() if you must keep
synchronous semantics; ensure CleanupAsync still awaits _webSocket.Close(),
flips _disposed before starting cleanup to make it idempotent, and remove the
fire-and-forget `_ = CleanupAsync()` call in IDisposable.Dispose so callers stop
returning before socket close completes.

}
Loading