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