using DodoSSH.Client.App.Platform; using DodoSSH.Client.Session; namespace DodoSSH.Client.App.Tests; /// /// One desktop instance per profile, and what the second launch does instead of starting. /// /// /// /// Both halves are real here: a real lock file in a real temporary profile, and a real named pipe. Neither /// is worth faking — the whole claim being made is about what the operating system does with an exclusive /// handle and a pipe name, and a fake of either would be a test of the fake. /// /// /// What is not reachable from here is a second process. Every test below runs in one, so the lock /// is proved against a second handle rather than a second executable, and the window that comes /// forward is a delegate rather than a window. docs/manual-checks.md 16.11 is the other side of that /// line. /// /// public sealed class SingleInstanceTests : IDisposable { /// /// A profile of its own per test, which is what makes these safe to run beside a real installation. /// /// /// The pipe name is derived from this directory — see SingleInstance.PipeNameFor — so a unique /// directory is also a unique pipe. Without that, a test here would ring the doorbell of whatever /// DodoSSH the developer happens to have running. /// private readonly ClientPaths paths = new(Path.Combine(Path.GetTempPath(), $"dodossh-instance-{Guid.CreateVersion7():N}")); /// /// No patience at all, which is what every test here wants. /// /// /// The shipped three seconds exist for a lock held by a process on its way out — an update relaunching /// itself, above all — and nothing here is on its way anywhere. Passing zero takes the give-up decision /// on the first attempt, so a refusal costs a millisecond rather than three seconds of the suite. /// private static readonly TimeSpan Impatient = TimeSpan.Zero; /// /// How long a doorbell is given to ring before the test says it never will. /// /// /// ◆ A bound at all, which is the lesson rather than the number. These two awaits were written /// against TestContext.Current.CancellationToken alone, which carries no timeout — so the run /// where a ring was genuinely lost did not fail, it hung, and took the whole CI job with it for /// as long as anybody let it. Twenty seconds is a hundred times what a local pipe needs and a fifth of /// what it costs to notice; a bound that is never reached costs nothing and a missing one costs the /// build. /// private static readonly TimeSpan LongEnoughForAPipe = TimeSpan.FromSeconds(20); /// public void Dispose() { if (Directory.Exists(paths.DataDirectory)) { Directory.Delete(paths.DataDirectory, recursive: true); } } [Fact] public void TheFirstLaunchClaimsTheProfile() { using var claim = SingleInstance.Claim(paths, Impatient); claim.ShouldNotBeNull(); File.Exists(paths.InstanceLockFile).ShouldBeTrue("the claim is a handle on a real file"); } /// /// The point of the whole exercise. Nobody is listening in this test, so the second claim takes the /// give-up path rather than the ask-and-leave one — which is why it is handed no patience at all. /// [Fact] public void ASecondLaunchIsRefused() { using var first = SingleInstance.Claim(paths, Impatient); first.ShouldNotBeNull(); SingleInstance.Claim(paths, Impatient).ShouldBeNull(); } /// /// ◆ There is no such thing as a stale lock here, and this is what says so. The handle is closed by the /// runtime when it is disposed and by the kernel when the process ends however it ends — which is the /// reason this is a file rather than a named mutex, whose abandonment is a state to be handled. /// [Fact] public void TheProfileIsClaimableAgainOnceTheFirstInstanceLetsGo() { var first = SingleInstance.Claim(paths, Impatient); first.ShouldNotBeNull(); first.Dispose(); using var second = SingleInstance.Claim(paths, Impatient); second.ShouldNotBeNull("the lock went with the handle"); } /// /// The other half: a refused launch is not a silent one. Somebody who double-clicks the icon and sees /// nothing has been told the application is broken, so the running instance is asked to come forward. /// [Fact] public async Task ARefusedLaunchAsksTheRunningInstanceToComeForward() { using var first = SingleInstance.Claim(paths, Impatient); first.ShouldNotBeNull(); var asked = new TaskCompletionSource(); await using var doorbell = SingleInstance.Listen(paths, () => asked.TrySetResult()); SingleInstance.Claim(paths, Impatient).ShouldBeNull("the profile is taken"); // Awaited rather than polled: the callback comes off a thread pool thread. Bounded explicitly, // because the test's own token is not — see LongEnoughForAPipe. await asked.Task.WaitAsync(LongEnoughForAPipe, TestContext.Current.CancellationToken); } /// /// /// A third and a fourth launch work as well as the second, and this is the test that caught the doorbell /// getting that wrong on Linux and only on Linux. A fresh NamedPipeServerStream per connection /// closes the listening socket between callers there, so the second ring was accepted by nobody while /// the launch that made it was told it had succeeded. See SingleInstance.Doorbell. /// /// /// Two rings back to back rather than one and a pause, because the gap is the whole defect: a test that /// waited for the first to be answered before making the second would pass against the broken version. /// /// [Fact] public async Task TheDoorbellAnswersMoreThanOnce() { using var first = SingleInstance.Claim(paths, Impatient); first.ShouldNotBeNull(); var rings = 0; var twice = new TaskCompletionSource(); await using var doorbell = SingleInstance.Listen( paths, () => { if (Interlocked.Increment(ref rings) == 2) { twice.TrySetResult(); } }); SingleInstance.AskTheRunningOneToComeForward(paths).ShouldBeTrue(); SingleInstance.AskTheRunningOneToComeForward(paths).ShouldBeTrue(); await twice.Task.WaitAsync(LongEnoughForAPipe, TestContext.Current.CancellationToken); } /// /// What the give-up path turns on. A lock held by something that is not listening — a process still /// starting, or one on its way out — answers no, and that is what makes Claim wait and try the /// lock again rather than concluding anything from the silence. /// [Fact] public void AskingWithNobodyListening_SaysSo() { SingleInstance.AskTheRunningOneToComeForward(paths).ShouldBeFalse(); } /// /// Two profiles, because a nightly and a release build are installed side by side on purpose and must /// not exclude each other — ADR 0013. The lock file is inside the profile and the pipe name is derived /// from it, so both halves are scoped by the one decision ClientPaths.ForChannel already made. /// [Fact] public void TwoProfilesDoNotExcludeEachOther() { var other = new ClientPaths(paths.DataDirectory + ".Nightly"); try { using var release = SingleInstance.Claim(paths, Impatient); using var nightly = SingleInstance.Claim(other, Impatient); release.ShouldNotBeNull(); nightly.ShouldNotBeNull("a second profile is a second application"); } finally { if (Directory.Exists(other.DataDirectory)) { Directory.Delete(other.DataDirectory, recursive: true); } } } }