Public Access
Put a window on screen while it starts, and refuse the second one
Two things a desktop application is expected to do and this one did not. ◆ THREE SECONDS OF NOTHING. Measured on a debug build against a cold profile: the main window arrives 3.1 seconds after the executable does. Nothing was on screen for any of it — the icon had been double-clicked and the desktop looked unchanged. The main window cannot come sooner. It needs a view model, and the view model needs composition to have opened the SQLite cache, bound the terminal's loopback socket, asked the platform about a TPM and read the install layout off disk. Its own IsStarting card already narrates what happens after it appears; this is the part before, which no arrangement of that window can cover. So SplashWindow, and it is measurably the point: 668 ms to the splash, 3.1 s to the window that replaces it. Two mechanics are load-bearing rather than incidental. The splash *is* desktop.MainWindow for a moment. The lifetime shows whatever that property holds once OnFrameworkInitializationCompleted returns; a window merely Show()n there would have no lifetime attached, and under ShutdownMode.OnLastWindowClose the first close of anything would end the process. Handing the property over is one assignment and Avalonia reads it lazily. And composition is posted at DispatcherPriority.Background rather than called. It is synchronous and slow — which is the entire argument for a splash — so running it inline would block the very dispatcher turn that has to paint the thing. The window is SizeToContent="Height", which is about the failure and not the splash: CouldNotStart carries an exception's own message, and inside a fixed height a long one would wrap and be clipped at exactly the moment that text is all the window is for. A composition that throws is caught rather than left to the dispatcher, for the same reason — before this there was nothing on screen to abandon, and now there is, so an escaping exception would leave a window saying "Starting…" over a process that had stopped. Escape closes it, and only then. ◆ AND ONE INSTANCE. Claimed in Main before Avalonia is configured, so a second launch starts nothing at all: no toolkit, no window, no splash flashing up before it thinks better of it. Exclusion is an exclusive handle on instance.lock inside the profile directory, and that location settles two questions at once. A nightly and a release build do not exclude each other — separate profiles, 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 from somewhere. A file handle rather than a named mutex because the kernel releases it however the process ends. There is no such thing as a stale lock here, which is a state a named mutex makes both platforms model differently and neither model simply. The second launch is not silent. Somebody who double-clicks an icon and sees nothing has been told the application is broken, so it rings a named pipe — named from a digest of the profile path, so it is scoped exactly as the lock is — and the running instance un-minimises and activates. Activate is a request and not a guarantee: Windows refuses the foreground to a process that did not receive the input, and the flashing taskbar button that results is still an answer. Three seconds of retrying when the lock is held by something that does not answer its pipe, and that window has one job. Velopack's apply ends this process and its updater starts the new build; the two are ordered, but a lock held a moment too long would make the successor refuse itself and vanish — an update that looked like an uninstall. The doorbell is disposed before Shutdown for the same reason, so the pipe name is free before the process is. ◆ VERIFIED BY RUNNING IT, WHICH IS THE ONLY WAY EITHER OF THESE COULD BE. Two launches of the built executable: the first stayed up with instance.lock in the profile, the second exited. Window sizes over time are where the two timings above come from — 420x160 then 1196x776. SingleInstanceTests covers what a single process can reach: the lock, its release by disposal, the doorbell answering more than once, and two profiles not excluding each other. SplashCardTests measures the one thing the splash can get wrong, which is a failure message long enough to need the height the window is willing to give it. Checks 1.12 and 16.11 are what needs a second executable and a real screen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
aff776cb42
commit
b8c94ecb73
12 files changed
+1044
-3
No files matched your search
@@ -51,6 +51,18 @@ internal static class LayoutHarness
|
||||
/// </remarks>
|
||||
internal const double HostDrawerWidth = 320;
|
||||
|
||||
/// <summary>The splash window's fixed width, from <c>SplashWindow.axaml</c>.</summary>
|
||||
/// <remarks>
|
||||
/// Its height is deliberately absent from this list, because the window has none to state: it is
|
||||
/// <c>SizeToContent="Height"</c> so that a failure message of any length is readable rather than
|
||||
/// clipped. <see cref="SplashMinimumHeight"/> is the floor under the ordinary one-line case, and
|
||||
/// <c>SplashCardTests</c> pins all three against the XAML.
|
||||
/// </remarks>
|
||||
internal const double SplashWidth = 420;
|
||||
|
||||
/// <inheritdoc cref="SplashWidth" />
|
||||
internal const double SplashMinimumHeight = 160;
|
||||
|
||||
/// <summary>The nav rail's fixed width, from <c>NavRail.axaml</c>.</summary>
|
||||
/// <remarks>v5b: 190 became 255, the design's own number rather than this bar's old approximation.</remarks>
|
||||
internal const double NavRailWidth = 255;
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
using Avalonia.Controls;
|
||||
using DodoSSH.Client.App.Views;
|
||||
|
||||
namespace DodoSSH.Client.App.Layout.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The splash, and the one thing about it that can go wrong.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Three stacked elements in a fixed-width window is not a layout with much to get wrong — except in one
|
||||
/// state. <c>SplashWindow.CouldNotStart</c> carries an exception's own message, whose length nothing in this
|
||||
/// application decides, and that message is the whole reason that window is still on screen. Inside a fixed
|
||||
/// height it would wrap and be clipped at exactly the wrong moment.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So what is measured here is that the message grows the card, and that the window is declared in a way
|
||||
/// that lets the growth through. <c>LayoutHarness.Unreachable</c> is no help: it reports interactive controls
|
||||
/// arranged where they cannot be used, and the splash has none — which is why these two assert on measured
|
||||
/// heights instead.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SplashCardTests
|
||||
{
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <summary>A message about as long as a real one gets: a path, a reason and a suggestion.</summary>
|
||||
private const string LongFailure =
|
||||
"The process cannot access the file 'C:\\Users\\somebody\\AppData\\Local\\DodoSSH\\cache.db' "
|
||||
+ "because it is being used by another process, and this build cannot open a second copy of it.";
|
||||
|
||||
/// <remarks>
|
||||
/// Pins the two constants and the mechanism against the XAML, the way
|
||||
/// <c>LayoutHarnessTests.TheHarnessMeasuresTheSizeTheWindowDeclares</c> pins the main window's minimum.
|
||||
/// <c>SizeToContent</c> is the one that matters: a window that lost it would clip the message and this
|
||||
/// suite's other test would go on passing, because the card would still be measuring the taller size it
|
||||
/// was never given.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheSplashIsDeclaredTheWayTheHarnessMeasuresIt()
|
||||
{
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var window = new SplashWindow();
|
||||
|
||||
try
|
||||
{
|
||||
window.Width.ShouldBe(LayoutHarness.SplashWidth);
|
||||
window.MinHeight.ShouldBe(LayoutHarness.SplashMinimumHeight);
|
||||
window.SizeToContent.ShouldBe(
|
||||
SizeToContent.Height,
|
||||
"a fixed height would clip a long failure message");
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The ordinary state has to fit the floor, or the window opens taller than its own minimum for the one
|
||||
/// line it normally shows — which would make <see cref="LayoutHarness.SplashMinimumHeight"/> a number
|
||||
/// that never applies to anything.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheStartingMessageFitsTheHeightTheSplashOpensAt()
|
||||
{
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() => MessageHeight(card => { }).ShouldBeLessThanOrEqualTo(
|
||||
LayoutHarness.SplashMinimumHeight,
|
||||
"the splash opens at its minimum and the one line it starts with has to fit"),
|
||||
Token);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// ◆ The test with something behind it. A message that wraps to three lines has to make the card ask for
|
||||
/// more room; if it did not, the text would be sitting under the bottom edge of the window and the only
|
||||
/// thing the splash exists to carry at that point would be the part nobody could read.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ALongFailureMessageAsksForMoreRoomRatherThanBeingClipped()
|
||||
{
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var starting = MessageHeight(card => { });
|
||||
var failed = MessageHeight(card => card.CouldNotStart(LongFailure));
|
||||
|
||||
failed.ShouldBeGreaterThan(
|
||||
starting,
|
||||
"the message wraps, so the card has to want the height the window then gives it");
|
||||
},
|
||||
Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What one card wants to be, at the splash's width, after the caller has had its say.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Hosted in a window before being measured, because the heading and the hint take their size and their
|
||||
/// wrapping from <c>App.axaml</c> — a card measured detached would be measuring unstyled text. Measured
|
||||
/// again afterwards against an unbounded height, which is what the real window's <c>SizeToContent</c>
|
||||
/// gives it and what the host window's own fixed height would otherwise clamp away.
|
||||
/// </remarks>
|
||||
private static double MessageHeight(Action<SplashCard> say)
|
||||
{
|
||||
var card = new SplashCard();
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
card, LayoutHarness.SplashWidth, LayoutHarness.SplashMinimumHeight);
|
||||
|
||||
try
|
||||
{
|
||||
say(card);
|
||||
|
||||
card.Measure(new Avalonia.Size(LayoutHarness.SplashWidth, double.PositiveInfinity));
|
||||
|
||||
return card.DesiredSize.Height;
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using DodoSSH.Client.App.Platform;
|
||||
using DodoSSH.Client.Session;
|
||||
|
||||
namespace DodoSSH.Client.App.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// One desktop instance per profile, and what the second launch does instead of starting.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// What is <em>not</em> reachable from here is a second process. Every test below runs in one, so the lock
|
||||
/// is proved against a second <em>handle</em> rather than a second executable, and the window that comes
|
||||
/// forward is a delegate rather than a window. <c>docs/manual-checks.md</c> 16.11 is the other side of that
|
||||
/// line.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SingleInstanceTests : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// A profile of its own per test, which is what makes these safe to run beside a real installation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The pipe name is derived from this directory — see <c>SingleInstance.PipeNameFor</c> — 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.
|
||||
/// </remarks>
|
||||
private readonly ClientPaths paths =
|
||||
new(Path.Combine(Path.GetTempPath(), $"dodossh-instance-{Guid.CreateVersion7():N}"));
|
||||
|
||||
/// <summary>
|
||||
/// No patience at all, which is what every test here wants.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan Impatient = TimeSpan.Zero;
|
||||
|
||||
/// <inheritdoc />
|
||||
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");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void ASecondLaunchIsRefused()
|
||||
{
|
||||
using var first = SingleInstance.Claim(paths, Impatient);
|
||||
first.ShouldNotBeNull();
|
||||
|
||||
SingleInstance.Claim(paths, Impatient).ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// ◆ 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.
|
||||
/// </remarks>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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, and the test's own
|
||||
// cancellation is what bounds a doorbell that never rings.
|
||||
await asked.Task.WaitAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A third and a fourth launch work as well as the second, which is what the accept loop is for: a
|
||||
/// <c>NamedPipeServerStream</c> that has served one client cannot serve another, so a doorbell that
|
||||
/// forgot to go round again would answer exactly one refused launch and then leave every later one to
|
||||
/// exit in silence.
|
||||
/// </remarks>
|
||||
[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(TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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 <c>Claim</c> wait and try the
|
||||
/// lock again rather than concluding anything from the silence.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void AskingWithNobodyListening_SaysSo()
|
||||
{
|
||||
SingleInstance.AskTheRunningOneToComeForward(paths).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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 <c>ClientPaths.ForChannel</c> already made.
|
||||
/// </remarks>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user