You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
C# WebSocket client close does not own or await its connect, ping, and receive tasks #30641
No exchange credentials or network connection are required for the first case:
varclient=newBaseExchange.WebSocketClient("ws://localhost:1234",null,(c,m)=>{});client.rejections["orphan"]=newNetworkError("orphan");awaitclient.Close();Console.WriteLine(client.rejections.Count);// 1 on v4.5.84Console.WriteLine(client.connect().IsCanceled);// false on v4.5.84
For the transport case, create a local HttpListener WebSocket server, call client.connect(), then await client.Close() while the peer stays connected. The client starts its ping loop with async void and launches connect/receive through detached Task.Run calls; Close returns after CloseOutputAsync, without awaiting the receive or ping loops. Repeat close while a connection is pending and verify no background task can reopen or dispatch a frame after close. For the callback case, pass an onError delegate that throws and await client.Close(); on v4.5.84 the callback exception faults close before transport teardown.
Expected behavior
Close should be idempotent, stop and await client-owned connection, ping, and receive work, settle pending connection and watch futures, and clear orphaned subscriptions/rejections. A failed-client cleanup should shut down that same client's transport without removing a healthy replacement installed under its URL. A throwing error callback should not prevent the transport from being closed. A later connect should not start work on a retired client.
Actual behavior
Close invokes onError and sends a close frame if the socket is open, but does not cancel, track, await, or dispose the client-owned task graph. reset clears futures and subscriptions but leaves orphaned rejections. On a never-connected client, connect() after Close() returns an unsettled connection task and starts a new background dial. CleanupClients removes the errored client by reference (correctly preserving a replacement) but does not close that failed client's transport. Exceptions from the error callback propagate into Close or an async void ping path.
The first code sample deterministically exposes retained state and connect-after-close on the release. An offline in-process-server test can also check that closing a connected client waits for its receive loop to stop without exchange access. The existing upstream retirement and keepalive-timeout suites should remain green with a fix.
Root cause
connect and Connect launch nested untracked tasks; onOpen launches PingLoop as async void and Receiving on another untracked task. Neither connect nor receive uses a lifetime cancellation token. Close has no shared completion task and no task ownership boundary, while reset does not clear rejections. The error delegate is invoked without containment, and the bridge detaches an errored client without closing it.
Suggested fix
Give each client one guarded shutdown task and a cancellation token shared by connect, ping, and receive. Track those tasks; on close mark the client retired before new connection attempts, send the close output, cancel and await owned work, and release transport resources and orphaned state. Avoid awaiting the ping task from itself when a keepalive timeout initiates close; preserve the existing close-frame behavior and the #30545 remove-by-reference rule. Catch error-callback failures without logging credentials or preventing shutdown. Add offline tests for close-before-connect, close-during-connect, callback failure, failed-client shutdown, idempotence, and the existing keepalive/retirement races.
Thanks for the detailed write-up. I checked it against current master, and cs/ccxt/ws/Client.cs does behave as you describe:
onOpen starts PingLoop() (an async void) inside a Task.Run whose result nothing awaits. connect → Task.Run(async () => Connect()) and Connect → Task.Run(... Receiving(webSocket)) are both fire-and-forget. ConnectAsync and ReceiveAsync both use CancellationToken.None.
Close() is just onError(ExchangeClosedByUser) plus CloseOutputAsync when the socket is Open. It doesn't cancel or await the receive or ping work.
reset clears futures and subscriptions but leaves rejections alone.
connect() only checks startedConnecting, so on a client that never connected, a call after Close() starts a new dial. Nothing ever completes that connected TCS.
onErrorCallback?.Invoke has no try/catch around it, so a callback that throws propagates into Close() before the close frame goes out.
This splits into small, independent fixes (clear rejections in reset, add a retired/error check to connect, wrap the callback in try/catch) and a larger lifetime refactor (one cancellation token plus tracked connect/receive/ping tasks and a single shared close task). The small fixes won't give you the "Close awaits owned work" guarantee on their own. One thing to watch if CleanupClients starts calling Close(): the call chain Close → onError → onErrorCallback → CleanupClients → Close only ends today because of the this.error != null early return. A shared close Task would make that re-entry explicit and idempotent, and it would also cover the keepalive-timeout path, where the ping loop must not await itself.
Client.cs is a hand-written file (it isn't transpiled from TS), so a fix goes straight into cs/ccxt/ws/. The offline HttpListener tests you list (close-before-connect, close-during-connect, a throwing callback, failed-client shutdown, idempotence) plus the existing retirement and keepalive suites look like the right acceptance bar.
Activity
carlotestor commentedon Sep 25, 2026
Thanks for the detailed write-up. I checked it against current
master, andcs/ccxt/ws/Client.csdoes behave as you describe:onOpenstartsPingLoop()(anasync void) inside aTask.Runwhose result nothing awaits.connect→Task.Run(async () => Connect())andConnect→Task.Run(... Receiving(webSocket))are both fire-and-forget.ConnectAsyncandReceiveAsyncboth useCancellationToken.None.Close()is justonError(ExchangeClosedByUser)plusCloseOutputAsyncwhen the socket isOpen. It doesn't cancel or await the receive or ping work.resetclearsfuturesandsubscriptionsbut leavesrejectionsalone.connect()only checksstartedConnecting, so on a client that never connected, a call afterClose()starts a new dial. Nothing ever completes thatconnectedTCS.onErrorCallback?.Invokehas notry/catcharound it, so a callback that throws propagates intoClose()before the close frame goes out.CleanupClientsremoves the client by reference (the WebSocket cleanup can leave the replaced client and its pending state unreleased during reconnect races #30463/fix(cs): retire ws clients by reference using the existing error marker #30545 rule) but never shuts down that client's transport.This splits into small, independent fixes (clear
rejectionsinreset, add a retired/error check toconnect, wrap the callback intry/catch) and a larger lifetime refactor (one cancellation token plus tracked connect/receive/ping tasks and a single shared close task). The small fixes won't give you the "Close awaits owned work" guarantee on their own. One thing to watch ifCleanupClientsstarts callingClose(): the call chainClose → onError → onErrorCallback → CleanupClients → Closeonly ends today because of thethis.error != nullearly return. A shared closeTaskwould make that re-entry explicit and idempotent, and it would also cover the keepalive-timeout path, where the ping loop must not await itself.Client.csis a hand-written file (it isn't transpiled from TS), so a fix goes straight intocs/ccxt/ws/. The offlineHttpListenertests you list (close-before-connect, close-during-connect, a throwing callback, failed-client shutdown, idempotence) plus the existing retirement and keepalive suites look like the right acceptance bar.