Skip to content

Commit d45f31e

Browse files
committed
Do not give up the queue because one accept failed
Listen returned on any SocketException from Accept, without looking at the error code or at cancellation. That treats a failure of one accept as a failure of the listener, and the two are not the same thing at all. A peer that resets while its connection is still sitting in the backlog surfaces exactly this way - WSAECONNRESET on Windows, ECONNABORTED on BSD and macOS - and is common enough that Kestrel retries it by name. The consequence here is worse than a dropped connection: the socket stays bound, so nobody else can take the queue for the life of the process, and every later client lands in a backlog that nothing is draining. PiperServer already continues in the same situation. Return only when cancelled, or on OperationAborted and Interrupted, which are how a stopped listener reports itself when the token has not been observed yet. The tests pin the rule rather than the race. I wrote an end to end one first - twenty abortive closes, then a real exchange - and deleted it after confirming it passes with the old `return` still in place: on Windows the accept succeeds and the reset surfaces later, during the read, which a different catch already handles. A test that cannot fail is worse than no test.
1 parent 4fec40a commit d45f31e

2 files changed

Lines changed: 56 additions & 1 deletion

File tree

src/DiffEngine.Tests/ViewerProtocolTests.cs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -733,6 +733,42 @@ public async Task AnUnresponsiveOwnerTimesOutRatherThanHanging()
733733
listener.Stop();
734734
}
735735
}
736+
/// <summary>
737+
/// Which socket failures mean the listener has stopped, as against one accept having failed.
738+
/// <para>
739+
/// Returning on any SocketException gave the queue away for the life of the process: the
740+
/// socket stays bound so nobody else can take it, and every later client lands in a backlog
741+
/// nothing is draining. A peer that resets while its connection sits in that backlog is the
742+
/// ordinary way to hit it - WSAECONNRESET on Windows, ECONNABORTED on BSD and macOS - and is
743+
/// why Kestrel retries the same condition.
744+
/// </para>
745+
/// </summary>
746+
[Test]
747+
[Arguments(SocketError.OperationAborted, true)]
748+
[Arguments(SocketError.Interrupted, true)]
749+
[Arguments(SocketError.ConnectionReset, false)]
750+
[Arguments(SocketError.ConnectionAborted, false)]
751+
[Arguments(SocketError.NetworkDown, false)]
752+
public async Task SocketFailuresThatStopTheListener(SocketError error, bool expected)
753+
{
754+
var exception = new SocketException((int) error);
755+
756+
await Assert.That(ViewerServer.IsStop(exception, default)).IsEqualTo(expected);
757+
}
758+
759+
/// <summary>
760+
/// And a cancelled token means stop whatever the code says, since that is the ordinary way a
761+
/// listener is shut down and the token may be observed before the exception is.
762+
/// </summary>
763+
[Test]
764+
public async Task ACancelledTokenStopsTheListenerWhateverTheCode()
765+
{
766+
using var cancel = new CancelSource();
767+
await cancel.CancelAsync();
768+
769+
await Assert.That(ViewerServer.IsStop(new((int) SocketError.ConnectionReset), cancel.Token)).IsTrue();
770+
}
771+
736772
[Test]
737773
public async Task AnAbsentOwnerIsNotAnError()
738774
{

src/DiffEngine/Protocol/ViewerServer.cs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,20 @@ public async Task Listen(Func<ViewerMessage, ViewerResponse> handle, Cancel canc
7676
// Same, on the frameworks where a stopped listener reports it this way.
7777
return;
7878
}
79-
catch (SocketException)
79+
catch (SocketException exception)
80+
when (IsStop(exception, cancel))
8081
{
8182
return;
8283
}
84+
catch (SocketException)
85+
{
86+
// A failure of one accept rather than of the listener. A peer that resets while
87+
// its connection sits in the backlog surfaces exactly this way - WSAECONNRESET on
88+
// Windows, ECONNABORTED on BSD and macOS - and returning gave the queue away for
89+
// the life of the process: the socket stays bound, so nobody else can take it,
90+
// and every later client lands in a backlog nothing is draining
91+
continue;
92+
}
8393

8494
// Each connection on its own task, so one slow exchange does not stop the next from
8595
// being answered. Accepting an inline snapshot legitimately takes seconds, and a
@@ -88,6 +98,15 @@ public async Task Listen(Func<ViewerMessage, ViewerResponse> handle, Cancel canc
8898
}
8999
}
90100

101+
/// <summary>
102+
/// Whether a socket failure means the listener itself has stopped, rather than one accept
103+
/// having failed. Cancellation is the ordinary way that happens; the two error codes are how a
104+
/// stopped listener reports itself when the token has not been observed yet.
105+
/// </summary>
106+
internal static bool IsStop(SocketException exception, Cancel cancel) =>
107+
cancel.IsCancellationRequested ||
108+
exception.SocketErrorCode is SocketError.OperationAborted or SocketError.Interrupted;
109+
91110
// ReSharper disable once ReplaceAsyncWithTaskReturn
92111
async Task<TcpClient> Accept(Cancel cancel)
93112
{

0 commit comments

Comments
 (0)