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)
{