Merge claude/windows-startup-performance-a615bc: start the Windows client in under a second by precompiling the publish and taking the cache off the UI thread
ci / build and test (push) Successful in 3m52s
ci / android head (push) Successful in 4m23s
ci / api image (push) Successful in 56s
ci / deploy ssh.dodotech.cloud (push) Successful in 22s
ci / desktop nightly (push) Successful in 4m30s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jaap-janandClaude Opus 5.5 committed 2026-10-23 11:52:19 +02:00
commit 3e607f57b9
3 files changed
+46 -8

No files matched your search

+5 -5
View File
@@ -266,6 +266,11 @@ internal sealed partial class DodoSshApp : Application
var viewModel = BuildShell(desktop, paths, caches, workspace, knownHosts, deviceKeys, connections, var viewModel = BuildShell(desktop, paths, caches, workspace, knownHosts, deviceKeys, connections,
browser, updates); 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 }; 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 // 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); 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); WireShutdown(desktop, viewModel, workspace, caches, others);
} }
@@ -89,6 +89,26 @@
that is correct for nobody. that is correct for nobody.
--> -->
<InvariantGlobalization>false</InvariantGlobalization> <InvariantGlobalization>false</InvariantGlobalization>
<!--
Precompiled, on a Windows publish, because a cold start here is mostly the JIT. The runtime pack's
own assemblies already ship precompiled; ours and every package's — Avalonia, Skia's bindings, EF
Core, SSH.NET — ship as IL, and before this every launch compiled the toolkit, the XAML and EF's
model building method by method before anything reached the screen.
Measured on a self-contained win-x64 publish, warm disk, median of six launches: the splash from
735 ms to 536 ms after the process starts, and the main window idle on its first screen from 2.6 s
to 1.4 s. The cost is size, because each method is carried as IL and as code: the publish grows from
146 MB to 175 MB and the Setup executable from 70 MB to 89 MB. That is the trade worth making for an
application opened many times a day and installed once — and it is a fifth of what dropping the
native symbols below saves, so the first install is still far smaller than it once was.
Conditioned on the RID rather than set outright, so a plain `dotnet build`, `dotnet run` and every
test project referencing this one are untouched: ReadyToRun is a publish step and needs a RID to
target. Windows only for now, because Windows is where it was measured; macOS and Linux publish the
same way and would very likely gain the same, but nothing here has timed them.
-->
<PublishReadyToRun Condition="$(RuntimeIdentifier.StartsWith('win-'))">true</PublishReadyToRun>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -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. /// 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 /// That is the point of migrating rather than recreating: a user who upgrades while offline must still
/// be able to open their vault. /// be able to open their vault.
/// <para>
/// <b>The cache is read on the thread pool, and that is the whole of what makes this asynchronous.</b>
/// 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.
/// </para>
/// </remarks> /// </remarks>
internal async Task StartAsync(CancellationToken cancellationToken) internal async Task StartAsync(CancellationToken cancellationToken)
{ {
@@ -3224,10 +3233,19 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
try try
{ {
paths.EnsureCreated(); // Taken here rather than inside the delegate, so that nothing on the thread pool reads this view
await caches.MigrateAsync(cancellationToken).ConfigureAwait(true); // 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) if (profile is null)
{ {