Keep the doorbell listening between callers, and stop a hung test taking CI with it
ci / build and test (push) Successful in 2m58s
ci / android head (push) Successful in 3m56s
ci / api image (push) Successful in 22s
ci / desktop nightly (push) Successful in 1m47s

The test step ran for twenty minutes and had to be stopped by hand. Eighteen suites
had passed; DodoSSH.Client.App.Tests never reported.

◆ ON UNIX, A FRESH SERVER PER CONNECTION LOSES THE NEXT ONE.

The doorbell's accept loop built a NamedPipeServerStream inside the loop, served one
caller and disposed it. That is fine on Windows and quietly wrong on Linux, where a
named pipe is a Unix domain socket: disposing the server closes the *listening*
socket and unlinks its path, so a second launch that connected while the first was
being served has its connection dropped. Its Connect returned, its write succeeded
into a buffer, and nobody ever read it — the launch was told it had rung the
doorbell and the window never came forward.

Reproduced in a container rather than reasoned about, because the two platforms
disagree and this machine is only one of them. Three rings back to back:

    one instance, Disconnect between callers   ->  3 of 3 answered
    a fresh instance per connection            ->  1 of 3 answered

So there is one server for the life of the doorbell now, Disconnect()ed between
conversations, and the socket is never not listening. Which makes leaveOpen on the
reader load-bearing too: a StreamReader disposes the stream it was given, and that
stream now has to survive into the next iteration.

Two things I had wrong about this, both worth recording. Cancelling
WaitForConnectionAsync *does* unblock a pending accept on Linux — that was my first
theory and the container disproved it, so DisposeAsync awaiting the loop was never
the problem. And the app itself never hung: the lost ring is a launch that is
ignored, not a process that stops.

◆ AND THE REASON IT COST TWENTY MINUTES RATHER THAN TWENTY SECONDS.

    await twice.Task.WaitAsync(TestContext.Current.CancellationToken);

That token carries no timeout. A ring that never arrives is then not a failing test,
it is a job that sits there — which is exactly what happened, and it is the more
expensive of the two mistakes because it turns any future bug of this shape into a
runner somebody has to go and stop. Both awaits are bounded now.

TheDoorbellAnswersMoreThanOnce keeps its shape deliberately: two rings back to back
rather than one and a pause, because the gap between accepts is the whole defect. A
test that waited for the first to be answered would pass against the broken version.

ci.yml had no timeout-minutes anywhere, on any job. Every job has a ceiling now, and
the test step has a tighter one of its own — ten minutes against a suite that takes
two — because a hang there is the likeliest of the lot and a step-level timeout names
the step instead of leaving a reader to work out which of fifteen was still running.
None of them is a performance budget; a build that legitimately grows past one should
have the number raised.

Verified both ways on Linux, in a container, against the committed tree and this one:

    31c836f  SingleInstanceTests  ->  hung in TheDoorbellAnswersMoreThanOnce,
                                      still going when killed at 3 minutes
    this     SingleInstanceTests  ->  7 of 7 in 1.3s
    this     the whole suite      ->  570 tests, 0 failed, 88s

And on Windows, where none of this was ever broken: 570 tests, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
laurensandClaude Opus 5 committed 2026-08-26 15:42:41 +02:00
1 parent 31c836f6ca
commit dabc691c35
3 files changed
+116 -27

No files matched your search

@@ -249,9 +249,30 @@ internal static class SingleInstance
/// <summary>The accept loop that answers refused launches.</summary>
/// <remarks>
/// One server instance at a time and a fresh stream per connection, which is the shape
/// <see cref="NamedPipeServerStream"/> wants: a stream that has served a client cannot serve another.
/// The loop is what makes a third and fourth launch work as well as the second.
/// <para>
/// One server instance for the life of the doorbell, disconnected between callers rather than disposed
/// and rebuilt. The loop is what makes a third and fourth launch work as well as the second.
/// </para>
/// <para>
/// ◆ <b>THE ONE INSTANCE IS THE WHOLE CORRECTNESS OF THIS CLASS ON LINUX, AND A FRESH ONE PER CALLER
/// LOSES LAUNCHES.</b> This was written the obvious way — a <c>using</c> inside the loop, a new stream
/// per connection — which is fine on Windows and quietly wrong on Unix, where a named pipe is a Unix
/// domain socket. Disposing the server there closes the <em>listening</em> socket and unlinks its path,
/// so a second launch that connected while the first was being served has its connection dropped on the
/// floor: its <c>Connect</c> returned, its write succeeded into a buffer, and nobody ever read it. The
/// launch was told it had rung the doorbell and the window never came forward.
/// </para>
/// <para>
/// Measured rather than reasoned about, in a container, because the two platforms disagree and this
/// machine is only one of them: three rings back to back are answered three times with one instance and
/// once with a fresh one per connection. <c>Disconnect</c> ends the conversation and keeps the socket, so
/// there is never a moment with nothing listening.
/// </para>
/// <para>
/// Which makes <c>leaveOpen</c> on the reader load-bearing as well. A <see cref="StreamReader"/> disposes
/// the stream it was given, and the stream is now the server that has to survive into the next
/// iteration — so the reader that was harmless per-connection would close the doorbell after one ring.
/// </para>
/// </remarks>
private sealed class Doorbell : IAsyncDisposable
{
@@ -287,27 +308,31 @@ internal static class SingleInstance
private async Task RunAsync(CancellationToken cancellationToken)
{
using var server = new NamedPipeServerStream(
name,
PipeDirection.In,
maxNumberOfServerInstances: 1,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
while (!cancellationToken.IsCancellationRequested)
{
try
{
using var server = new NamedPipeServerStream(
name,
PipeDirection.In,
maxNumberOfServerInstances: 1,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
using var reader = new StreamReader(server, Encoding.UTF8);
var said = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
if (string.Equals(said, ComeForward, StringComparison.Ordinal))
// leaveOpen, because this server outlives the conversation. See the remark above.
using (var reader = new StreamReader(server, Encoding.UTF8, false, 1024, leaveOpen: true))
{
comeForward();
var said = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
if (string.Equals(said, ComeForward, StringComparison.Ordinal))
{
comeForward();
}
}
HangUp(server);
}
catch (OperationCanceledException)
{
@@ -315,11 +340,33 @@ internal static class SingleInstance
}
catch (IOException)
{
// A client that connected and hung up, or a name the platform took away. Round again:
// giving up here would leave every later launch with nothing to talk to, which is the
// silent-exit behaviour this class exists to avoid.
// A client that connected and hung up mid-sentence. Round again: giving up here would
// leave every later launch with nothing to talk to, which is the silent-exit behaviour
// this class exists to avoid.
HangUp(server);
}
}
}
/// <summary>Ends one conversation without ending the doorbell.</summary>
/// <remarks>
/// Guarded, because <c>Disconnect</c> throws on a server that is not connected — which is the state
/// an <see cref="IOException"/> above may already have left it in, and the state a second call would
/// find. Throwing out of the error path would take the loop with it.
/// </remarks>
private static void HangUp(NamedPipeServerStream server)
{
try
{
if (server.IsConnected)
{
server.Disconnect();
}
}
catch (InvalidOperationException)
{
// Already disconnected. Nothing to end.
}
}
}
}