From 3c07a3d4b770aa0ac5305262311553c27e3681b6 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Mon, 31 Aug 2026 15:38:04 +1000 Subject: [PATCH 1/2] Show the tray icon 40ms sooner The icon appeared 121ms into a warm start, and most of what came before it was work nothing about the icon needed. Measured from process start, it is now 81ms. Time to fully ready is unchanged at around 210ms, which is the honest summary: this moves the wait rather than removing much of it, and the wait it moves is the one the user is watching. Inner reads the settings file after creating the NotifyIcon rather than before. Nothing between the two needs a setting - AlwaysKill and the hot keys are both used further down - and until that line there is nothing in the notification area to look at. Still after the mutex, so a second instance exits without ever showing an icon. Worth 33ms. Images are decoded on first use. A static constructor decoded all eleven the moment anything touched one, and the first thing to touch one is the tray icon, where only Default is wanted; four more are on the fixed menu items and the last five are only ever shown when something is pending. Lazy rather than field initialisers, which would not have helped, since the runtime initialises a type's static fields together. Thread safe because the icon is set from the scan timer as well as from the UI thread. Worth 5ms, and it stops decoding images for a menu that may never have anything in it. Settings deserialise through a source generated context, and the file is read whole and parsed in one go rather than off an async FileStream. Together 31ms to 21ms - less than it looks, and less than the generator promises: it is worth 4ms of that, and the async machinery 6ms, while the remaining 20ms is System.Text.Json being loaded and jitted for the first time, which no amount of generated code avoids for a file under 200 bytes. Kept anyway, since it is strictly cheaper and keeps the reflection based serialiser off the startup path, which is the part that grows on a cold start. Deliberately not touched: Logging.Init is 23ms and still ahead of the icon. It could move after it, but then "Mutex already exists. Exiting." and anything the Main catch reports would have nowhere to go, and a tray that failed to start with no explanation costs more than 23ms is worth. The 43ms in new ContextMenuStrip, and 15ms building its items, are already after the icon is up. Deferring them to the first right click only moves the cost to somewhere the user is waiting on it. The duplicate EnableVisualStyles and SetCompatibleTextRenderingDefault calls go with the reorder: Main already makes both immediately before calling Inner. --- src/DiffEngineTray/Images/Images.cs | 91 +++++++++++-------- src/DiffEngineTray/Program.cs | 22 +++-- .../Settings/SettingsContext.cs | 20 ++++ src/DiffEngineTray/Settings/SettingsHelper.cs | 11 ++- 4 files changed, 93 insertions(+), 51 deletions(-) create mode 100644 src/DiffEngineTray/Settings/SettingsContext.cs diff --git a/src/DiffEngineTray/Images/Images.cs b/src/DiffEngineTray/Images/Images.cs index 59b19cee..cf76bfa3 100644 --- a/src/DiffEngineTray/Images/Images.cs +++ b/src/DiffEngineTray/Images/Images.cs @@ -1,43 +1,58 @@ +/// +/// Decoded on first use rather than all together. +/// +/// A static constructor decoded all eleven the moment anything touched one, and the first thing to +/// touch one is the tray icon - before the icon exists, so the cost is time the user spends looking +/// at an empty notification area. Only is wanted then. Of the nine menu +/// images, four are on the fixed menu items and five are only ever shown when something is pending, +/// so most of that decoding was for a menu that may never have anything in it. +/// +/// +/// The fields still initialise together, which is fine: constructing one +/// allocates and nothing more. Thread safe by default, and it has to be, because the icon is set +/// from the scan timer as well as from the UI thread. +/// +/// public static class Images { - static Stream GetStream(string name) => - Assembly.GetExecutingAssembly().GetManifestResourceStream($"DiffEngineTray.Images.{name}")!; + public static Icon Active => active.Value; + public static Icon Default => defaultIcon.Value; + public static Image Exit => exit.Value; + public static Image Delete => delete.Value; + public static Image AcceptAll => acceptAll.Value; + public static Image Accept => accept.Value; + public static Image Discard => discard.Value; + public static Image VisualStudio => visualStudio.Value; + public static Image Folder => folder.Value; + public static Image Options => options.Value; + public static Image Link => link.Value; + + static Lazy active = LazyIcon("active.ico"); + static Lazy defaultIcon = LazyIcon("default.ico"); + static Lazy exit = LazyImage("exit.png"); + static Lazy delete = LazyImage("delete.png"); + static Lazy acceptAll = LazyImage("acceptAll.png"); + static Lazy accept = LazyImage("accept.png"); + static Lazy discard = LazyImage("discard.png"); + static Lazy visualStudio = LazyImage("vs.png"); + static Lazy folder = LazyImage("folder.png"); + static Lazy options = LazyImage("cogs.png"); + static Lazy link = LazyImage("link.png"); - static Images() - { - using var activeStream = GetStream("active.ico"); - Active = new(activeStream); - using var defaultStream = GetStream("default.ico"); - Default = new(defaultStream); - using var exitStream = GetStream("exit.png"); - Exit = Image.FromStream(exitStream); - using var deleteStream = GetStream("delete.png"); - Delete = Image.FromStream(deleteStream); - using var acceptAllStream = GetStream("acceptAll.png"); - AcceptAll = Image.FromStream(acceptAllStream); - using var acceptStream = GetStream("accept.png"); - Accept = Image.FromStream(acceptStream); - using var discardStream = GetStream("discard.png"); - Discard = Image.FromStream(discardStream); - using var vsStream = GetStream("vs.png"); - VisualStudio = Image.FromStream(vsStream); - using var folderStream = GetStream("folder.png"); - Folder = Image.FromStream(folderStream); - using var optionsStream = GetStream("cogs.png"); - Options = Image.FromStream(optionsStream); - using var linkStream = GetStream("link.png"); - Link = Image.FromStream(linkStream); - } + static Lazy LazyIcon(string name) => + new(() => + { + using var stream = GetStream(name); + return new Icon(stream); + }); - public static Image VisualStudio { get; } - public static Image Link { get; } - public static Image Discard { get; } - public static Image Accept { get; } - public static Image AcceptAll { get; } - public static Image Delete { get; } - public static Image Exit { get; } - public static Image Folder { get; } - public static Image Options { get; } - public static Icon Active { get; } - public static Icon Default { get; } + static Lazy LazyImage(string name) => + new(() => + { + using var stream = GetStream(name); + return Image.FromStream(stream); + }); + + static Stream GetStream(string name) => + Assembly.GetExecutingAssembly().GetManifestResourceStream($"DiffEngineTray.Images.{name}")!; } diff --git a/src/DiffEngineTray/Program.cs b/src/DiffEngineTray/Program.cs index 3cdd7717..2da8932a 100644 --- a/src/DiffEngineTray/Program.cs +++ b/src/DiffEngineTray/Program.cs @@ -23,16 +23,6 @@ static async Task Main() static async Task Inner() { - var settings = await GetSettings(); - if (settings == null) - { - return; - } - - LockedFilesHandler.AlwaysKill = settings.AlwaysKillLockingProcesses; - - Application.EnableVisualStyles(); - Application.SetCompatibleTextRenderingDefault(false); var tokenSource = new CancelSource(); var cancel = tokenSource.Token; using var mutex = new Mutex(true, "DiffEngine", out var createdNew); @@ -52,6 +42,10 @@ static async Task Inner() Log.Error(exception, "Failed to write the tray version marker"); } + // Before the settings file rather than after it, because nothing between here and the + // first use of a setting needs one, and until this line the user is looking at an empty + // notification area. Still after the mutex, so a second instance exits without ever + // showing an icon. using var icon = new NotifyIcon { Icon = Images.Default, @@ -62,6 +56,14 @@ static async Task Inner() void Warn(string message) => icon.ShowBalloonTip(10000, "DiffEngineTray", message, ToolTipIcon.Warning); + var settings = await GetSettings(); + if (settings == null) + { + return; + } + + LockedFilesHandler.AlwaysKill = settings.AlwaysKillLockingProcesses; + // Ownership of the inline queue is decided here, once, by whether the bind succeeds, and // never transfers. Usually the tray wins, because it starts at login. A viewer that was // already running keeps the queue for as long as it lives, and this tray drives it diff --git a/src/DiffEngineTray/Settings/SettingsContext.cs b/src/DiffEngineTray/Settings/SettingsContext.cs new file mode 100644 index 00000000..75678aaa --- /dev/null +++ b/src/DiffEngineTray/Settings/SettingsContext.cs @@ -0,0 +1,20 @@ +/// +/// Source generated serialisation for , rather than the reflection based +/// serialiser. +/// +/// Worth less than it looks. Reading the settings file cost 31ms of a 200ms start, and moving the +/// metadata to compile time took 4ms off that. Almost all of the rest is System.Text.Json being +/// loaded and jitted for the first time, which no amount of generated code avoids - the file +/// itself is under 200 bytes. Kept because it is strictly cheaper, and because it keeps the +/// reflection based serialiser off the startup path altogether, which is the part that grows on a +/// cold start. +/// +/// +/// Both directions go through it, so a save does not reach the reflection based serialiser either. +/// The written JSON is unchanged: the generator's defaults are the same defaults, which is what +/// SettingsHelperTests.ReadWrite pins. +/// +/// +[JsonSerializable(typeof(Settings))] +partial class SettingsContext : + JsonSerializerContext; diff --git a/src/DiffEngineTray/Settings/SettingsHelper.cs b/src/DiffEngineTray/Settings/SettingsHelper.cs index 52c21d11..e3887dc9 100644 --- a/src/DiffEngineTray/Settings/SettingsHelper.cs +++ b/src/DiffEngineTray/Settings/SettingsHelper.cs @@ -15,8 +15,13 @@ public static async Task Read() Settings settings; if (File.Exists(FilePath)) { - await using var stream = File.OpenRead(FilePath); - settings = (await JsonSerializer.DeserializeAsync(stream))!; + // Read whole and parsed in one go rather than deserialised off an async FileStream. + // The file is under 200 bytes, so the async machinery cost more than the read did: + // 27ms for the call against 21ms without it. What is left is System.Text.Json being + // loaded and jitted for the first time, which nothing here can avoid - see + // . + var json = File.ReadAllBytes(FilePath); + settings = JsonSerializer.Deserialize(json, SettingsContext.Default.Settings)!; } else { @@ -52,7 +57,7 @@ internal static async Task WriteFile(Settings settings) await using (var stream = File.Create(temp)) { - await JsonSerializer.SerializeAsync(stream, settings); + await JsonSerializer.SerializeAsync(stream, settings, SettingsContext.Default.Settings); } await Swap(temp); From a54035fe0a8df8bb8e9df912a47dd158c711a9af Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Mon, 31 Aug 2026 19:31:15 +1000 Subject: [PATCH 2/2] Read settings synchronously The only await left in SettingsHelper.Read was a 2 byte first run write, and it kept a state machine and a Task on the path that runs at every startup. Read and GetSettings are now sync, and the ReSharper suppression on the ReadAllBytes call goes with them - that inspection only fires inside an async method. Write stays async: Swap retries File.Move ten times with a 20ms delay, and blocking the UI thread for 200ms while a backup holds the file is the failure it exists to avoid. --- src/DiffEngineTray.Tests/SettingsHelperTests.cs | 2 +- src/DiffEngineTray/LockedFilesHandler.cs | 2 +- src/DiffEngineTray/Program.cs | 6 +++--- src/DiffEngineTray/Settings/OptionsFormLauncher.cs | 2 +- src/DiffEngineTray/Settings/SettingsHelper.cs | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/DiffEngineTray.Tests/SettingsHelperTests.cs b/src/DiffEngineTray.Tests/SettingsHelperTests.cs index 9d27c39e..c98aaa25 100644 --- a/src/DiffEngineTray.Tests/SettingsHelperTests.cs +++ b/src/DiffEngineTray.Tests/SettingsHelperTests.cs @@ -63,7 +63,7 @@ await SettingsHelper.Write( AlwaysKillLockingProcesses = true }); - var result = await SettingsHelper.Read(); + var result = SettingsHelper.Read(); await Verify(result); } diff --git a/src/DiffEngineTray/LockedFilesHandler.cs b/src/DiffEngineTray/LockedFilesHandler.cs index 4d40d995..1a91d59c 100644 --- a/src/DiffEngineTray/LockedFilesHandler.cs +++ b/src/DiffEngineTray/LockedFilesHandler.cs @@ -26,7 +26,7 @@ static void Persist() => { try { - var settings = await SettingsHelper.Read(); + var settings = SettingsHelper.Read(); settings.AlwaysKillLockingProcesses = true; await SettingsHelper.Write(settings); } diff --git a/src/DiffEngineTray/Program.cs b/src/DiffEngineTray/Program.cs index 2da8932a..f7fb7271 100644 --- a/src/DiffEngineTray/Program.cs +++ b/src/DiffEngineTray/Program.cs @@ -56,7 +56,7 @@ static async Task Inner() void Warn(string message) => icon.ShowBalloonTip(10000, "DiffEngineTray", message, ToolTipIcon.Warning); - var settings = await GetSettings(); + var settings = GetSettings(); if (settings == null) { return; @@ -181,11 +181,11 @@ internal static IEnumerable BuildKeyBindings(Settings settings, Trac internal record KeyBinding(int Id, HotKey HotKey, Action Action); - static async Task GetSettings() + static Settings? GetSettings() { try { - return await SettingsHelper.Read(); + return SettingsHelper.Read(); } catch (Exception exception) { diff --git a/src/DiffEngineTray/Settings/OptionsFormLauncher.cs b/src/DiffEngineTray/Settings/OptionsFormLauncher.cs index fcca3225..7bdaff9c 100644 --- a/src/DiffEngineTray/Settings/OptionsFormLauncher.cs +++ b/src/DiffEngineTray/Settings/OptionsFormLauncher.cs @@ -10,7 +10,7 @@ public static async Task Launch(KeyRegister keyRegister, Tracker tracker) return; } - var settings = await SettingsHelper.Read(); + var settings = SettingsHelper.Read(); using var form = new OptionsForm( settings, newSettings => Save(keyRegister, tracker, settings, newSettings)); diff --git a/src/DiffEngineTray/Settings/SettingsHelper.cs b/src/DiffEngineTray/Settings/SettingsHelper.cs index e3887dc9..01df441c 100644 --- a/src/DiffEngineTray/Settings/SettingsHelper.cs +++ b/src/DiffEngineTray/Settings/SettingsHelper.cs @@ -10,7 +10,7 @@ static SettingsHelper() FilePath = Path.Combine(directory, "settings.json"); } - public static async Task Read() + public static Settings Read() { Settings settings; if (File.Exists(FilePath)) @@ -25,7 +25,7 @@ public static async Task Read() } else { - await File.WriteAllTextAsync(FilePath, "{}"); + File.WriteAllText(FilePath, "{}"); settings = new(); }