using System.IO.Pipes; using System.Security.Cryptography; using System.Text; using DodoSSH.Client.Session; namespace DodoSSH.Client.App.Platform; /// /// One desktop instance per profile, and a way for a second launch to raise the first. /// /// /// /// Per profile rather than per machine, which decides two things at once. The lock lives in /// , so a nightly and a release build never exclude each other — /// they are separate applications by design, ADR 0013 — and two people signed in to one machine each get /// their own instance. A global mutex would have had to re-derive both of those from somewhere. /// /// /// Why one instance at all. The cache is a SQLite file with an outbox in it, and the whole design /// assumes one writer: says so about two machines sharing a profile through a /// cloud sync client, and two processes on one machine is the same hazard with a shorter path to it. The /// terminal's loopback listener is the visible half — the second process fails to bind, gets a port of its /// own or refuses to start a workspace at all — and the cache is the half that would quietly corrupt. /// /// /// A file handle rather than a named mutex, because the operating system releases it when the /// process ends however it ends. A named mutex abandoned by a killed process is a state both /// platforms model differently and neither models simply; an exclusive handle is closed by the kernel on /// Windows and by the file descriptor going away on Unix, so there is no such thing as a stale lock here. /// /// /// And a named pipe for the other half, because exiting silently is not an answer. Somebody who /// double-clicks the icon and sees nothing happen has been told the application is broken. The second /// process asks the first to come forward and then leaves, which is what every desktop application does and /// what makes the exclusion invisible rather than mysterious. /// /// /// A launch that carries a link passes the link on. Clicking "Open in DodoSSH" in Spelunk starts /// this executable with a dodossh:// link, and while DodoSSH is already running that start is a /// second launch like any other — so the doorbell has a second message, which carries the link to the /// instance that can act on it. See . /// /// internal static class SingleInstance { /// The one thing a second launch has to say. /// /// Compared against a constant rather than parsed, for the reason MainWindow's release-focus /// message is: one message exists and treating anything else as a command would be a wider door than /// this needs. Anything may connect to a per-user pipe — it is not a security boundary — so what arrives /// is checked rather than trusted. /// private const string ComeForward = "dodossh.come-forward"; /// The other thing a second launch may say: come forward, and open this link. /// /// /// Followed by the link, on the same one line. Checked as strictly as is, and /// for the same reason: anything on this machine may write to the pipe, so the link is parsed again here /// by , and a message that is not exactly this prefix and a link that /// passes is ignored whole — the window does not even come forward for it. /// /// /// A second message rather than a second line after , so that an older running /// build, which compares the whole line against that constant, ignores a link it cannot act on rather /// than half-obeying it. /// /// private const string OpenLinkPrefix = "dodossh.open-link "; /// The longest message the doorbell reads before it stops listening to that caller. /// /// The longest link a second launch could send, and its prefix. A caller that goes on past it is not a /// DodoSSH, and reading a line of whatever length it likes would let it decide how much memory this /// process spends on it. /// private const int MaxMessageLength = 64 + SpelunkLink.MaxLength; /// How long a second launch waits for the first to answer its pipe. /// /// Short, because the pipe is local and the process on the other end is either listening or not. It is /// not a bound on anything slow; it is how long to wait before concluding that whoever holds the lock /// cannot be talked to. /// private const int AnswerTimeoutMilliseconds = 500; /// How long to keep trying for a lock that is held by nobody who answers. /// /// /// ◆ This is the window an update relaunch lands in, and without it an update would look like a /// crash. Velopack's apply ends this process and its updater then starts the new build; the two are /// ordered — the updater waits on the old process id — but the whole sequence is one where a lock held a /// moment too long would make the relaunched application exit on the spot, having apparently uninstalled /// itself. The same window covers an ordinary double-double-click, where the first process holds the file /// and has not reached its pipe yet. /// /// /// Only reached when nobody answers the pipe. A live instance replies in milliseconds and the second /// launch is gone before any of this matters. /// /// private static readonly TimeSpan DefaultPatience = TimeSpan.FromSeconds(3); /// How long to wait between attempts within that window. private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(100); /// /// Claims this profile for this process, or asks whoever holds it to come forward. /// /// /// A handle to hold for the life of the process, or when another instance has it — /// in which case that instance has already been asked to raise its window and this process should end. /// /// The profile to claim. Its directory is created if it is not there. /// /// How long to keep trying a lock nobody answers for, or null for . Named /// by a test so the give-up path does not cost the suite three seconds; the application never passes it. /// /// /// The link this launch was started with, if any — passed to the running instance instead of a bare /// request to come forward. /// internal static IDisposable? Claim(ClientPaths paths, TimeSpan? patience = null, SpelunkLink? link = null) { ArgumentNullException.ThrowIfNull(paths); try { paths.EnsureCreated(); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) { // No profile directory means no lock file, and every build before this one started anyway. What // follows will fail on its own terms and say something better than this could. return NoClaim.Instance; } // Counted rather than clocked. Thread.Sleep is not a clock and reading one here would be pretending // otherwise — what this bounds is the number of attempts, and the wall time is whatever the platform // makes of them. var attemptsLeft = (int)Math.Ceiling( (patience ?? DefaultPatience).TotalMilliseconds / RetryDelay.TotalMilliseconds); while (true) { if (TryTake(paths.InstanceLockFile) is { } taken) { return taken; } // Asked before waiting, so the ordinary case — an instance that is up and running — costs one // round trip on a local pipe rather than the whole window below. if (AskTheRunningOneToComeForward(paths, link)) { return null; } if (attemptsLeft-- <= 0) { // Held by something that will not talk. Giving up rather than starting anyway, because what // is being protected is a database with one writer, and "probably nobody" is not an answer // worth corrupting it for. return null; } Thread.Sleep(RetryDelay); } } /// /// Answers the launches that were refused, for as long as the returned handle is held. /// /// /// is invoked on a thread pool thread, because that is where a pipe /// accept completes. A caller that touches a window has to marshal. /// /// The profile this instance claimed. /// What to do when another launch asks for the window. /// /// What to do with a link another launch passed on, after ; null to ignore /// links, which then still bring the window forward. Invoked on the same thread pool thread. /// internal static IAsyncDisposable Listen( ClientPaths paths, Action comeForward, Action? openLink = null) { ArgumentNullException.ThrowIfNull(paths); ArgumentNullException.ThrowIfNull(comeForward); var listener = new Doorbell(PipeNameFor(paths), comeForward, openLink); listener.Start(); return listener; } /// Tells the instance that holds this profile to raise its window, and to open a link if given one. /// Whether anything was listening. /// /// Internal rather than private because it is the half a test can drive: the exclusion is a file handle /// and proves itself, and this is the part with a protocol in it. /// internal static bool AskTheRunningOneToComeForward(ClientPaths paths, SpelunkLink? link = null) { ArgumentNullException.ThrowIfNull(paths); try { using var pipe = new NamedPipeClientStream( serverName: ".", PipeNameFor(paths), PipeDirection.Out); pipe.Connect(AnswerTimeoutMilliseconds); using var writer = new StreamWriter(pipe, Encoding.UTF8) { AutoFlush = true }; // The link as this build writes it, rather than as it arrived: the receiver parses what a DodoSSH // sent, and a link that parsed here has exactly one spelling. See SpelunkLink.ToString. writer.WriteLine(Message(link)); return true; } catch (Exception exception) when (exception is TimeoutException or IOException or UnauthorizedAccessException) { // Nothing there, or nothing willing. The caller decides what that means; here it is only ever // "no". return false; } } /// What a launch says down the pipe: come forward, with or without a link to open. internal static string Message(SpelunkLink? link) => link is null ? ComeForward : OpenLinkPrefix + link; /// /// Reads what a caller said: whether it is a ring at all, and the link that came with it. /// /// /// True for exactly , with no link, and for followed /// by a link accepts, with that link. False for everything else, /// including a link that is almost right — the caller then does nothing at all. /// internal static bool TryRead(string? said, out SpelunkLink? link) { link = null; if (said is null || said.Length > MaxMessageLength) { return false; } if (string.Equals(said, ComeForward, StringComparison.Ordinal)) { return true; } if (said.StartsWith(OpenLinkPrefix, StringComparison.Ordinal) && SpelunkLink.TryParse(said[OpenLinkPrefix.Length..], out var parsed)) { link = parsed; return true; } return false; } /// Takes the lock, or answers null if somebody else has it. /// /// The file is left in place rather than deleted on close. FileOptions.DeleteOnClose looks /// tidier and is worse: it makes the file's existence part of the protocol, so a second process opening /// it while the first is closing gets a handle to something on its way out. An empty file that is always /// there and sometimes locked has one fewer state. /// private static IDisposable? TryTake(string lockFile) { try { return new FileStream( lockFile, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None, bufferSize: 1, FileOptions.None); } catch (IOException) { // Somebody else holds it. The one expected failure, and the whole point. return null; } catch (UnauthorizedAccessException) { // A profile this user cannot write to, which is a broken installation rather than a second // instance. Answered as a claim so the application starts and fails on something legible. return NoClaim.Instance; } } /// /// The pipe both sides name, derived from the profile so that it is scoped exactly as the lock is. /// /// /// A digest of the path rather than the path itself: a pipe name may not contain a directory separator /// on Windows, and on Unix the name becomes a file in a temporary directory whose length is bounded. /// Eight bytes of SHA-256, which is not doing security work — it is making one string out of another /// deterministically, and string.GetHashCode cannot because it is randomised per process. /// private static string PipeNameFor(ClientPaths paths) { var digest = SHA256.HashData(Encoding.UTF8.GetBytes(paths.DataDirectory)); return "dodossh-" + Convert.ToHexStringLower(digest.AsSpan(0, 8)); } /// A claim over nothing, for the cases where there was nothing to claim. private sealed class NoClaim : IDisposable { internal static NoClaim Instance { get; } = new(); public void Dispose() { // Nothing was taken. } } /// The accept loop that answers refused launches. /// /// /// 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. /// /// /// ◆ THE ONE INSTANCE IS THE WHOLE CORRECTNESS OF THIS CLASS ON LINUX, AND A FRESH ONE PER CALLER /// LOSES LAUNCHES. This was written the obvious way — a using 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 listening 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 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. /// /// /// 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. Disconnect ends the conversation and keeps the socket, so /// there is never a moment with nothing listening. /// /// /// Which makes leaveOpen on the reader load-bearing as well. A 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. /// /// private sealed class Doorbell : IAsyncDisposable { private readonly string name; private readonly Action comeForward; private readonly Action? openLink; private readonly CancellationTokenSource lifetime = new(); private Task? loop; internal Doorbell(string name, Action comeForward, Action? openLink) { this.name = name; this.comeForward = comeForward; this.openLink = openLink; } internal void Start() => loop = RunAsync(lifetime.Token); /// public async ValueTask DisposeAsync() { await lifetime.CancelAsync().ConfigureAwait(false); if (loop is { } running) { // Awaited rather than abandoned, so the pipe name is free by the time this returns — which // matters for exactly one caller: an update that is about to relaunch this application and // would otherwise race its own successor for the name. await running.ConfigureAwait(false); } lifetime.Dispose(); } private async Task RunAsync(CancellationToken cancellationToken) { using var server = new NamedPipeServerStream( name, PipeDirection.In, maxNumberOfServerInstances: 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous); while (!cancellationToken.IsCancellationRequested) { try { await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false); // leaveOpen, because this server outlives the conversation. See the remark above. using (var reader = new StreamReader(server, Encoding.UTF8, false, 1024, leaveOpen: true)) { var said = await ReadBoundedLineAsync(reader, cancellationToken).ConfigureAwait(false); if (TryRead(said, out var link)) { comeForward(); if (link is not null) { openLink?.Invoke(link); } } } HangUp(server); } catch (OperationCanceledException) { return; } catch (IOException) { // 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); } } } /// /// Reads one line, or null once it runs past or the caller hangs up. /// /// /// reads a line of whatever length the /// caller sends, and the caller is anybody on this machine. A character at a time is cheap here — the /// reader buffers underneath, and a real message is one short line. /// private static async Task ReadBoundedLineAsync( StreamReader reader, CancellationToken cancellationToken) { var line = new StringBuilder(); var next = new char[1]; while (await reader.ReadAsync(next.AsMemory(), cancellationToken).ConfigureAwait(false) == 1) { if (next[0] == '\n') { return line.ToString().TrimEnd('\r'); } if (line.Length == MaxMessageLength) { return null; } line.Append(next[0]); } return line.Length == 0 ? null : line.ToString().TrimEnd('\r'); } /// Ends one conversation without ending the doorbell. /// /// Guarded, because Disconnect throws on a server that is not connected — which is the state /// an 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. /// private static void HangUp(NamedPipeServerStream server) { try { if (server.IsConnected) { server.Disconnect(); } } catch (InvalidOperationException) { // Already disconnected. Nothing to end. } } } }