diff --git a/docs/platform-flags.md b/docs/platform-flags.md index 8baace3..b4a69d7 100644 --- a/docs/platform-flags.md +++ b/docs/platform-flags.md @@ -388,6 +388,19 @@ agent of our own plus ProxyJump covers the real use cases. **The SSH suite pulls `linuxserver/openssh-server` from Docker Hub**, which is rate-limited for unauthenticated pulls. If CI starts failing on image pulls rather than on tests, that is why. +**That suite has an intermittent `The connection was closed by the remote host`**, on whichever test +connects first, within tens of milliseconds. Seen in CI and reproducible locally. *Mitigated, not +solved:* `SshServerFixture` now raises sshd's `MaxStartups` from its compiled-in `10:30:100`, which +refuses connections at random past ten unauthenticated ones in flight — reachable because xUnit runs +test classes in parallel and most of them connect. The fixture comment carries the full argument and +is explicit that the cure is unproven. + +**And the reason it is unproven is a measurement trap worth not falling into twice.** Docker +throughput on the Windows development machine swings enough to swamp the effect: the identical +unmodified suite ran 85/85 clean and, an hour later, failed 13 runs out of 15. Any before/after flake +comparison taken there is noise. Measure this class of thing in CI, or make the server say why — +raise sshd's `LogLevel`, disable Ryuk so the container outlives the run, and read `docker logs`. + **MSIX packaging is ruled out, not merely deprioritised.** A packaged app runs WebView2 in an AppContainer where loopback connections are blocked without a `CheckNetIsolation` exemption. The terminal data plane *is* a loopback WebSocket, so MSIX would break the product outright. Velopack diff --git a/tests/DodoSSH.Client.Ssh.Tests/SshServerFixture.cs b/tests/DodoSSH.Client.Ssh.Tests/SshServerFixture.cs index 765cf21..22ebd31 100644 --- a/tests/DodoSSH.Client.Ssh.Tests/SshServerFixture.cs +++ b/tests/DodoSSH.Client.Ssh.Tests/SshServerFixture.cs @@ -112,6 +112,61 @@ public sealed class SshServerFixture : IAsyncLifetime /// this image as hardening, not as a behaviour worth reproducing: nothing else here opens a channel of /// any kind, so allowing it changes what exactly one suite can do and what none of the others see. /// + /// + /// ◆ MaxStartups is raised here too, against a flake this suite has and that this change is + /// a mitigation for rather than a proven cure. The distinction is stated because the evidence + /// stops short of the claim, and a later reader deserves to know which. + /// + /// + /// What is established: sshd's compiled-in default is 10:30:100 — past ten + /// unauthenticated connections in flight it refuses new ones at random, thirty percent of the + /// time, rising to always at a hundred — and the image ships the line commented out, so that default + /// was what ran. xUnit runs test classes in parallel and most classes here open a connection, so ten + /// in flight is reachable in the opening seconds. A refused connection presents to the client as + /// SshConnectionException: The connection was closed by the remote host within tens of + /// milliseconds, on whichever test connects at the wrong moment — which is exactly the observed + /// failure, seen in CI and reproduced locally. + /// + /// + /// What is not established is that this limit is the only cause, because the flake rate could + /// not be measured reliably. On the development machine the identical unmodified suite ran 85/85 clean + /// and, an hour later, failed 13 runs out of 15 — Docker throughput on that host swings far enough to + /// swamp the effect being measured. Any before/after comparison taken there is noise, and two were, + /// before that was noticed. + /// + /// + /// It is committed anyway, on the narrower argument that it is right regardless: a connection throttle + /// is hardening this suite has no interest in reproducing. It exists to test an SSH client, not to + /// survive a rate limit, and a test server that drops connections at random is a bad test server + /// whether or not it is the cause of this particular flake. + /// + /// + /// Not fixed by serialising the suite, which would have hidden it and cost the parallelism, and + /// not by retrying the connect, which would have made the client's own reconnect behaviour untestable + /// by burying it in the fixture. The limit is a property of a hardened server that this suite has no + /// interest in reproducing — it exists to test an SSH client, not to survive a throttle. + /// + /// + /// Replaced in place rather than appended, because sshd_config takes the first value it finds + /// for a keyword: an appended line would be dead the day the image ships an uncommented one of its own. + /// + /// + /// ◆ The reload window is the other candidate, and it is deliberately not guarded against. + /// SIGHUP makes sshd close its listeners and re-execute itself, and pkill returns when + /// the signal is delivered rather than when that has finished — so in principle a connection made + /// immediately afterwards is refused, producing this same exception. A wait that opened connections + /// until the server answered with its banner three times running was written, and then removed: it + /// could not be shown to change anything either, and a fixture carrying two unproven fixes for one + /// symptom is worse than one, because the next person has to disprove both. + /// + /// + /// If this flake returns, that is the next thing to try. Two things to know before trying it: the two + /// causes are indistinguishable from the client, so a fix can only be judged by a repeat run and never + /// by whether the next run passes — and the repeat run has to happen somewhere with stable Docker + /// throughput, which the development machine is not. Better still, make sshd say why: raise its + /// LogLevel here, disable Ryuk so the container outlives the run, and read + /// docker logs. A MaxStartups refusal names itself there; a reload does not. + /// /// private async Task AllowTcpForwardingAsync() { @@ -119,14 +174,16 @@ public sealed class SshServerFixture : IAsyncLifetime "sh", "-c", "sed -i 's/^AllowTcpForwarding no/AllowTcpForwarding yes/' /config/sshd/sshd_config" + + " && sed -i 's/^#*MaxStartups .*/MaxStartups 200/' /config/sshd/sshd_config" + " && pkill -HUP sshd", ]); if (result.ExitCode != 0) { throw new InvalidOperationException( - $"Could not enable TCP forwarding on the test server: {result.Stderr}"); + $"Could not reconfigure the test server: {result.Stderr}"); } + } ///