using System.IO.Pipes;
using System.Security.Cryptography;
using System.Text;
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);
}
}
}
// ---- A launch that carries a link ----
///
/// Clicking "Open in DodoSSH" in Spelunk while DodoSSH is running is a second launch like any other, and
/// the link has to reach the instance that can act on it — along with the window coming forward.
///
[Fact]
public async Task ARefusedLaunchWithALink_PassesTheLinkOn()
{
using var first = SingleInstance.Claim(paths, Impatient);
first.ShouldNotBeNull();
var cameForward = new TaskCompletionSource();
var opened = new TaskCompletionSource();
await using var doorbell = SingleInstance.Listen(
paths,
() => cameForward.TrySetResult(),
link => opened.TrySetResult(link));
var link = Link();
SingleInstance.Claim(paths, Impatient, link).ShouldBeNull("the profile is taken");
await cameForward.Task.WaitAsync(LongEnoughForAPipe, TestContext.Current.CancellationToken);
(await opened.Task.WaitAsync(LongEnoughForAPipe, TestContext.Current.CancellationToken)).ShouldBe(link);
}
[Fact]
public void ThePlainRing_IsStillThePlainRing()
{
// An older running build compares the whole line against this, so a launch with no link must go on
// saying exactly it.
SingleInstance.Message(link: null).ShouldBe("dodossh.come-forward");
SingleInstance.TryRead("dodossh.come-forward", out var link).ShouldBeTrue();
link.ShouldBeNull();
}
[Fact]
public void ALinkMessage_ReadsBackToTheSameLink()
{
var link = Link();
SingleInstance.TryRead(SingleInstance.Message(link), out var read).ShouldBeTrue();
read.ShouldBe(link);
}
///
/// Anything on this machine can write to the pipe, so a message that is not exactly one of the two is
/// ignored whole — the window does not even come forward for it.
///
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("dodossh.come-forward ")]
[InlineData("DODOSSH.COME-FORWARD")]
[InlineData("dodossh.open-link")]
[InlineData("dodossh.open-link https://evil.example")]
[InlineData("dodossh.open-link dodossh://spelunk/connect?api=http%3A%2F%2Fevil.example&server=0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b")]
[InlineData("dodossh.open-link dodossh://spelunk/connect?api=https%3A%2F%2Fapi.spelunk.example.com&server=not-a-server")]
[InlineData("dodossh.run calc.exe")]
public void AnythingElse_IsNotARing(string? said)
{
SingleInstance.TryRead(said, out var link).ShouldBeFalse();
link.ShouldBeNull();
}
[Fact]
public void AnOverlongMessage_IsNotARing()
{
SingleInstance.TryRead("dodossh.open-link " + new string('a', 64 * 1024), out _).ShouldBeFalse();
}
[Fact]
public async Task AGarbledRing_IsIgnored_AndTheDoorbellKeepsAnswering()
{
using var first = SingleInstance.Claim(paths, Impatient);
first.ShouldNotBeNull();
var rings = 0;
var answered = new TaskCompletionSource();
await using var doorbell = SingleInstance.Listen(
paths,
() =>
{
Interlocked.Increment(ref rings);
answered.TrySetResult();
});
// Somebody else's program, writing something that is not a ring and going on far past a line. The
// doorbell stops reading and hangs up part way, which the writer sees as a broken pipe.
using (var pipe = new NamedPipeClientStream(".", PipeNameOf(paths), PipeDirection.Out))
{
await pipe.ConnectAsync(TestContext.Current.CancellationToken);
try
{
await pipe.WriteAsync(
Encoding.UTF8.GetBytes(new string('x', 20_000) + "\n"),
TestContext.Current.CancellationToken);
}
catch (IOException)
{
// Hung up on, which is the point.
}
}
SingleInstance.AskTheRunningOneToComeForward(paths).ShouldBeTrue();
await answered.Task.WaitAsync(LongEnoughForAPipe, TestContext.Current.CancellationToken);
rings.ShouldBe(1, "the garbage rang nothing, and the real ring after it was still answered");
}
[Fact]
public void TheWindowsHandler_RunsThisExecutableWithTheLinkAsOneArgument()
{
Assert.SkipUnless(OperatingSystem.IsWindows(), "The registry handler exists only on Windows.");
const string executable = @"C:\Users\Jane Doe\AppData\Local\DodoSSH.Desktop\current\DodoSSH.exe";
const string nightly = @"C:\Users\Jane Doe\AppData\Local\DodoSSH.Desktop.Nightly\current\DodoSSH.exe";
WindowsUrlScheme.CommandFor(executable)
.ShouldBe(@"""C:\Users\Jane Doe\AppData\Local\DodoSSH.Desktop\current\DodoSSH.exe"" ""%1""");
// Uninstalling one channel must not take the handler away from the other if it registered since.
WindowsUrlScheme.IsOurs(WindowsUrlScheme.CommandFor(executable), executable).ShouldBeTrue();
WindowsUrlScheme.IsOurs(WindowsUrlScheme.CommandFor(nightly), executable).ShouldBeFalse();
WindowsUrlScheme.IsOurs(null, executable).ShouldBeFalse();
}
private static SpelunkLink Link() =>
new("https://api.spelunk.example.com", Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b"), "web 01", "203.0.113.10");
/// The pipe name SingleInstance derives, so a test can write to it as a stranger would.
private static string PipeNameOf(ClientPaths profile)
{
var digest = SHA256.HashData(Encoding.UTF8.GetBytes(profile.DataDirectory));
return "dodossh-" + Convert.ToHexStringLower(digest.AsSpan(0, 8));
}
}