Skip to content

Commit 5a5c616

Browse files
authored
Do not give up the queue because one accept failed (#790)
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 8faf6dc commit 5a5c616

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
@@ -741,6 +741,42 @@ public async Task AnUnresponsiveOwnerTimesOutRatherThanHanging()
741741
listener.Stop();
742742
}
743743
}
744+
/// <summary>
745+
/// Which socket failures mean the listener has stopped, as against one accept having failed.
746+
/// <para>
747+
/// Returning on any SocketException gave the queue away for the life of the process: the
748+
/// socket stays bound so nobody else can take it, and every later client lands in a backlog
749+
/// nothing is draining. A peer that resets while its connection sits in that backlog is the
750+
/// ordinary way to hit it - WSAECONNRESET on Windows, ECONNABORTED on BSD and macOS - and is
751+
/// why Kestrel retries the same condition.
752+
/// </para>
753+
/// </summary>
754+
[Test]
755+
[Arguments(SocketError.OperationAborted, true)]
756+
[Arguments(SocketError.Interrupted, true)]
757+
[Arguments(SocketError.ConnectionReset, false)]
758+
[Arguments(SocketError.ConnectionAborted, false)]
759+
[Arguments(SocketError.NetworkDown, false)]
760+
public async Task SocketFailuresThatStopTheListener(SocketError error, bool expected)
761+
{
762+
var exception = new SocketException((int) error);
763+
764+
await Assert.That(ViewerServer.IsStop(exception, default)).IsEqualTo(expected);
765+
}
766+
767+
/// <summary>
768+
/// And a cancelled token means stop whatever the code says, since that is the ordinary way a
769+
/// listener is shut down and the token may be observed before the exception is.
770+
/// </summary>
771+
[Test]
772+
public async Task ACancelledTokenStopsTheListenerWhateverTheCode()
773+
{
774+
using var cancel = new CancelSource();
775+
await cancel.CancelAsync();
776+
777+
await Assert.That(ViewerServer.IsStop(new((int) SocketError.ConnectionReset), cancel.Token)).IsTrue();
778+
}
779+
744780
/// <summary>
745781
/// An owner that answers with an error is not an absent one. Collapsing the two into false
746782
/// meant a refused inline was read as "nobody is there", so a second viewer was launched, it

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)