Skip to content

Dial-path disconnect no longer waits for the peer's Close frame - #168

Merged
chrisuthe merged 1 commit into
mainfrom
fix/dial-path-close-handshake
Aug 12, 2026
Merged

Dial-path disconnect no longer waits for the peer's Close frame#168
chrisuthe merged 1 commit into
mainfrom
fix/dial-path-close-handshake

Conversation

@chrisuthe

Copy link
Copy Markdown
Member

Closes #160. Independent of #167 — no file overlap.

Both reasons for deferring this were wrong

#160 was filed on shape rather than evidence, and gave two reasons its risk profile differed from #143. Checking them is what this PR started as, and neither holds:

it is followed by CleanupWebSocketAsync in a finally, so the socket is released regardless

A finally runs when the try exits. An await that never returns never exits, so CleanupWebSocketAsync is unreachable in exactly the case it looks like it covers.

it is not on a disposal path invoked with CancellationToken.None

DisposeAsync (SendspinConnection.cs:622) calls await DisconnectAsync(GoodbyeReasons.Shutdown) with no token, so cancellationToken is defaultCancellationToken.None. That is the disposal path with None.

It is worse than "no caller happens to pass a token": ISendSpinClient.DisconnectAsync(string reason) has no CancellationToken parameter, so no caller can pass one. Every call site in src/ passes only a reason.

So the dial path sits in the same position the listen path did before #143 — an initiating close awaiting a frame the peer may never send, on the disposal path, with a token that cannot fire. An app exit against an unresponsive server hangs indefinitely.

The fix

CloseOutputAsync instead of CloseAsync, matching WebSocketClientConnection and carrying the reasoning at the call site — including why the finally and the token do not save it, since that is the exact inference that deferred this once already.

Verification

The peer completes the RFC 6455 upgrade and then never reads the socket again, so it cannot answer a Close frame. That makes this a statement about the shape of the call rather than a race: the outcome is the same on every run.

Result
CloseOutputAsync (this PR) passes in 61 ms
CloseAsync (reverted) fails at the 5 s bound

The test bounds its own wait with Task.WhenAny rather than letting a regression hang. A hanging test burns the whole CI job instead of failing — which is how #143 stayed invisible — and the peer is dropped in a finally before the assert so a parked close faults rather than wedging the run.

  • Clean dotnet build -c Release --no-incremental: 0 errors, both library TFMs.
  • Full suite: 730 passed, 0 failed (729 + 1 — count checked).

Release notes updated: the entry now covers both teardown paths and names DisposeAsync as the reason it mattered.

SendspinConnection.DisconnectAsync called the blocking WebSocket.CloseAsync, which
performs the full closing handshake and waits for the peer's Close frame. A
crashed, hung or non-conformant peer never sends one, so the await never
completes. Same shape as the listen-path defect fixed in #143.

#160 filed this on shape rather than evidence, on two premises that both turn out
to be false:

- "it is followed by CleanupWebSocketAsync in a finally, so the socket is released
  regardless". A finally runs when the try exits; an await that never returns
  never exits, so the cleanup is unreachable in precisely the case it appears to
  cover.
- "it is not on a disposal path invoked with CancellationToken.None". DisposeAsync
  calls DisconnectAsync(GoodbyeReasons.Shutdown) with no token, so the token is
  CancellationToken.None. ISendSpinClient.DisconnectAsync has no CancellationToken
  parameter at all, and no call site in src passes one, so there is no caller that
  could supply a token capable of firing.

So an app exit could hang indefinitely against an unresponsive server.

Switched to CloseOutputAsync, matching WebSocketClientConnection and carrying the
same reasoning at the call site.

The test peer completes the RFC 6455 upgrade and then never reads the socket
again, so it cannot answer a Close frame -- deterministic rather than timing
dependent. Discrimination measured: CloseOutputAsync passes in 61 ms,
CloseAsync fails at the 5 s bound. The wait is bounded in the test rather than
left to hang, since a hanging test burns the CI job instead of failing, which is
how #143 stayed invisible.

Suite 730 green, clean Release build on both TFMs.
@chrisuthe
chrisuthe merged commit 100b9e9 into main Aug 12, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dial path performs the same unbounded WebSocket close handshake fixed in #143

1 participant