C# WebSocket client close does not own or await its connect, ping, and receive tasks · Issue #30641 · ccxt/ccxt · GitHub
Skip to content

C# WebSocket client close does not own or await its connect, ping, and receive tasks #30641

Open

Description

@alert101

Operating System

Windows 11 x64

Programming Language

C#

CCXT Version

4.5.84

Description

This is separate from the client-identity race fixed by #30545.

Affected file(s)

  • cs/ccxt/ws/Client.cs (connect, Connect, onOpen, PingLoop, Receiving, reset, Close)
  • cs/ccxt/ws/Exchange.WsBridge.cs (CleanupClients)

Steps to reproduce

No exchange credentials or network connection are required for the first case:

var client = new BaseExchange.WebSocketClient("ws://localhost:1234", null, (c, m) => { });
client.rejections["orphan"] = new NetworkError("orphan");
await client.Close();
Console.WriteLine(client.rejections.Count); // 1 on v4.5.84
Console.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.

Activity

  1. carlotestor commented on Sep 25, 2026

    @carlotestor
    Collaborator

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugc#wsWebSocket (aka ccxt.pro)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions