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.
}
}
}
}