From 4965813b738c3532e8dfaf644f5e5b65608c4569 Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Fri, 23 Oct 2026 11:52:07 +0200 Subject: [PATCH] Start the Windows client in under a second: precompile the publish, and take the cache off the UI thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured on a self-contained win-x64 publish, warm disk, median of six launches from process start: the main window idle on its first screen went from 2.56 s to about 0.9 s, and the splash from 735 ms to about 430 ms. Two causes, and they were most of it. ◆ THE JIT. Every launch compiled Avalonia, the XAML, EF Core's model building and our own code method by method before anything reached the screen. The runtime pack ships precompiled; nothing else did. The head now sets PublishReadyToRun on a win-* publish, which alone took the main window to 1.4 s. It costs size — the publish grows from 146 MB to 175 MB, the Setup executable from 70 MB to 89 MB — and is conditioned on the RID so build, run and the tests are untouched. Cross-compiling it on Linux works: reproduced CI's win-x64 publish in the Alpine SDK container, and vpk pack accepts the result. ◆ THE MIGRATION RAN INLINE. EF Core's async methods over SQLite complete synchronously, so StartAsync's awaited migration and profile read ran inside Compose's dispatcher turn: a quarter to half a second during which the window could not be built. They now run on the thread pool, and the desktop head calls StartAsync before building the window rather than after, so the two overlap. The Android head already called it first and gets the thread-pool half for free. The window can now appear a few tens of milliseconds before the profile is read, which shows the existing "Opening the local cache…" card briefly — the state the splash was always meant to hand over to, and one that until now was never on screen at all. macOS and Linux would very likely gain the same from ReadyToRun; they are left out only because nothing here has timed them. Co-Authored-By: Claude Opus 5.5 --- src/DodoSSH.Client.App/App.axaml.cs | 10 ++++---- .../DodoSSH.Client.App.csproj | 20 ++++++++++++++++ .../ViewModels/MainWindowViewModel.cs | 24 ++++++++++++++++--- 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/src/DodoSSH.Client.App/App.axaml.cs b/src/DodoSSH.Client.App/App.axaml.cs index 0849859..23c854a 100644 --- a/src/DodoSSH.Client.App/App.axaml.cs +++ b/src/DodoSSH.Client.App/App.axaml.cs @@ -266,6 +266,11 @@ internal sealed partial class DodoSshApp : Application var viewModel = BuildShell(desktop, paths, caches, workspace, knownHosts, deviceKeys, connections, browser, updates); + // Started rather than awaited, and before the window rather than after it: the migration runs on the + // thread pool, so it overlaps building the window instead of following it — see StartAsync. The view + // model shows its own progress and handles its own failures, so discarding the task here is safe. + _ = viewModel.StartAsync(CancellationToken.None); + desktop.MainWindow = new MainWindow { DataContext = viewModel }; // The other half of Program's claim on this profile: a second launch was refused, and this is what @@ -286,11 +291,6 @@ internal sealed partial class DodoSshApp : Application viewModel.OpenLink(first); } - // Started rather than awaited: the framework's initialisation must not block on a schema - // migration. The view model shows its own progress and handles its own failures, which is why - // discarding the task here is safe rather than merely convenient. - _ = viewModel.StartAsync(CancellationToken.None); - WireShutdown(desktop, viewModel, workspace, caches, others); } diff --git a/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj b/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj index dcaef2c..0717b9b 100644 --- a/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj +++ b/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj @@ -89,6 +89,26 @@ that is correct for nobody. --> false + + + true diff --git a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs index a5d9aa2..1616c12 100644 --- a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs +++ b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs @@ -3213,6 +3213,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp /// Migrating happens before unlock and touches no encrypted content — only the shape of the tables. /// That is the point of migrating rather than recreating: a user who upgrades while offline must still /// be able to open their vault. + /// + /// The cache is read on the thread pool, and that is the whole of what makes this asynchronous. + /// EF Core over SQLite has no real asynchronous I/O: its async methods complete synchronously, so + /// awaiting them on the UI thread ran the migration and the profile read inline, inside the caller's + /// dispatcher turn. Measured on a release build, that was a quarter to half a second of a cold start — + /// EF building its model and SQLite loading — during which the window could not be built or drawn. + /// Off the UI thread it overlaps the window's construction instead, which is why both heads call this + /// before building their view rather than after. + /// /// internal async Task StartAsync(CancellationToken cancellationToken) { @@ -3224,10 +3233,19 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp try { - paths.EnsureCreated(); - await caches.MigrateAsync(cancellationToken).ConfigureAwait(true); + // Taken here rather than inside the delegate, so that nothing on the thread pool reads this view + // model's state. + var opener = Opener(); - var profile = await Opener().ReadProfileAsync(cancellationToken).ConfigureAwait(true); + var profile = await Task.Run( + async () => + { + paths.EnsureCreated(); + await caches.MigrateAsync(cancellationToken).ConfigureAwait(false); + + return await opener.ReadProfileAsync(cancellationToken).ConfigureAwait(false); + }, + cancellationToken).ConfigureAwait(true); if (profile is null) {