Dial-path disconnect no longer waits for the peer's Close frame - #168
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
A
finallyruns when thetryexits. An await that never returns never exits, soCleanupWebSocketAsyncis unreachable in exactly the case it looks like it covers.DisposeAsync(SendspinConnection.cs:622) callsawait DisconnectAsync(GoodbyeReasons.Shutdown)with no token, socancellationTokenisdefault—CancellationToken.None. That is the disposal path withNone.It is worse than "no caller happens to pass a token":
ISendSpinClient.DisconnectAsync(string reason)has noCancellationTokenparameter, so no caller can pass one. Every call site insrc/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
CloseOutputAsyncinstead ofCloseAsync, matchingWebSocketClientConnectionand carrying the reasoning at the call site — including why thefinallyand 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.
CloseOutputAsync(this PR)CloseAsync(reverted)The test bounds its own wait with
Task.WhenAnyrather 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 afinallybefore the assert so a parked close faults rather than wedging the run.dotnet build -c Release --no-incremental: 0 errors, both library TFMs.Release notes updated: the entry now covers both teardown paths and names
DisposeAsyncas the reason it mattered.