diff --git a/docs/linux-port/investigations/startup-time.md b/docs/linux-port/investigations/startup-time.md new file mode 100644 index 000000000000..2262645f7a95 --- /dev/null +++ b/docs/linux-port/investigations/startup-time.md @@ -0,0 +1,130 @@ +# Startup time investigation + +Reported baseline: host start to first window about 1.0 s warm; fresh Debian 13 +container about 3.5 s warm and 6.8 s first run. These are inputs, not measurements +made in this sandbox. A mapped window, first backdrop render and usable folder +listing are different endpoints; record all three when comparing changes. + +## Phases and tracing + +Set `FILES_TRACE=1` for opt-in, bounded asynchronous stderr records. Existing +`PerformanceTrace` scopes report duration, parent id and thread, without paths. +New scopes cover: + +| Phase | Trace operation | Work and ordering | +| --- | --- | --- | +| Root environment | startup.root-environment | Security checks before any host setup; must stay synchronous. | +| Instance ownership | startup.single-instance | D-Bus ownership/forwarding, socket fallback; must precede window creation. Existing 3 s connect and 5 s forwarding budgets retained to avoid duplicate windows. | +| Cursor | startup.cursor | X resources, then up to two read-only gsettings processes before host creation. | +| App resources | startup.app-resources | App XAML and exception hook setup; stale archive cleanup already runs on a worker. | +| Activation | startup.activation | DI, eager managers, placement, backdrop, navigation and component scheduling. | +| DI registration | startup.configure-services | File logger, registrations, provider build. Most singleton registrations are lazy. | +| User settings | startup.user-settings | Synchronous JSON settings initialization when first resolved; theme, placement and restored tabs depend on settings. | +| Window resources | startup.window-resources | MainWindow constructor/XAML/title setup (base constructor outside scope). | +| Theme discovery | startup.theme-detection | Portal read followed by GTK environment, KDE file and gsettings fallback. | +| First backdrop | startup.first-backdrop | Frame/theme/backdrop preparation, compositor commit, activation and first Rendered event with loaded root. | +| Navigation/components | startup.navigation-and-components | Command-line/session navigation and background component scheduling after backdrop render. | + +Scopes overlap: do not sum nested durations. Program/native runtime loading before +App construction is not included in activation. Existing `dbus-connect`, +`dbus.theme`, `process-start` and file-operation traces provide further detail. +First-backdrop is a framework render acknowledgement, not proof that the display +server presented pixels. External window timestamps are still needed. + +## Changes and expected savings + +Desktop startup previously called Activate directly and immediately constructed +main content. It now uses the existing ActivateWithBackdropAsync helper, which +awaits a lightweight backdrop render before navigation. Terminal discovery and +Sentry configuration follow that render. Main content, drives and startup tasks +therefore cannot monopolize the UI thread before the backdrop acknowledgement. +Settings, placement, eager manager resolution and initial theme stay before it; +moving those blindly could break dependencies or flash the wrong theme. + +Theme portal discovery now has a 250 ms synchronous wait, bounded connect and +read waits, and returns each read's result directly instead of shared `_lastRead`. +Late worker failures are caught. Theme gsettings waits shrink from 1500 to 250 ms; +cursor fallback waits shrink from 1500 to 250 ms per missing setting. Slow portal +sessions can initially use the local fallback; the existing portal watcher still +updates theme on change. User-action portal and mount timeouts remain unchanged. +The theme watcher may finish after the foreground wait; this is not cancellation +of a remote D-Bus method. Existing shared connection lifecycle is retained. + +Expected savings, **unmeasured**: + +- Unresponsive theme portal: up to 1.75 s less foreground waiting per detection. +- Slow theme gsettings fallback: up to 1.25 s less waiting per detection. +- Two slow cursor fallback processes: up to 2.5 s less waiting total. +- Deferring navigation, terminal discovery and Sentry: moves their pre-yield CPU + and I/O cost after backdrop render; amount depends on session and first-run data. +- Healthy services: timeout reductions alone save approximately zero; process + startup, XAML parsing, fontconfig and renderer initialization remain. + +These are wait-budget differences, not additive predictions for the supplied +3.5/6.8 s timings. Process creation and termination, local file reads and native +calls are not hard-bounded by these waits. Container renderer choice, font caches, +filesystem caches and portal availability can explain substantial differences. + +## Services, storage and icons + +App.xaml.cs initializes XAML/handlers, not the entire service graph. App.Desktop.cs +registers services then eagerly resolves QuickAccessManager, StorageHistoryWrapper, +FileTagsManager, LibraryManager, logger and AppModel. AppModel is constructed on +the UI thread for Clipboard.ContentChanged. Preserve that affinity. + +UserSettingsService/BaseJsonSettings and serializers use JSON files. The desktop +FileTagsDatabase.Linux implementation lazily loads JSON; StorageHistoryWrapper is +in memory. A SQLite package reference does not establish a startup DB open. +InitializeAppComponentsAsync already schedules pinned items, libraries, cloud +sections and tag updates on workers after navigation. DrivesViewModel constructs +a watcher and enumerates via GetDrivesAsync when updated; this work is downstream +of navigation. Keep mount/polkit operation budgets unchanged. + +LinuxIconThemeProvider discovers theme via environment and GTK/KDE configuration +and indexes icon files; it does not spawn a portal/gsettings process for theme +selection. MainWindow already schedules its window icon after the next render. +PortalWallpaperService is invoked for wallpaper actions, not eager startup +wallpaper discovery; DesktopWallpaperService is a stub. Its 5 s call/10 minute +confirmation budgets should not be shortened for startup. + +## Publishing, JIT and globalization + +Files.App.csproj already sets PublishReadyToRun=true for non-Debug builds with a +RID, with composite R2R disabled. scripts/linux/publish.sh supplies Release and a +Linux RID, so its tarball publish already requests R2R. The script now explicitly +passes PublishReadyToRun=true so this packaging intent is visible. The analysis +publish profile also enables it. No published tarball was available for binary +verification here. The previous project comment's 25–30% claim is not a measured +result for the supplied Debian environment. + +R2R does not eliminate assembly loading, XAML initialization, native library +loading or all JIT compilation. No explicit tiered compilation/TieredPGO or +runtime ReadyToRun overrides were found in the app/publishing paths examined; +retain runtime defaults rather than trading later responsiveness for speculative +startup gains. Benchmark the same Release RID publication with R2R on/off, and +inspect Files.runtimeconfig.json plus DOTNET_/COMPlus_ overrides. Compare file +size and page faults as well as latency; a larger R2R payload can cost cold I/O. + +Do not enable InvariantGlobalization for Files.App. AppLanguageHelper constructs +named cultures and the date formatter uses the selected language's CultureInfo; +localized formatting and sorting need real globalization. Only the isolated +ElevationHelper explicitly uses invariant mode. Preserve app ICU availability; +measure its loading cost rather than disabling culture support. + +## Validation and next measurements + +The sandbox cannot build/run the Uno desktop app. Platform tests validate the +Linux changes, but do not compile App.Desktop/MainWindow. Review the app changes +by reference searches and run desktop build and UI checks on a supported host. +Use scripts/linux/headless-run.sh with its private display/throwaway home; never +use the user's display or real settings. Capture FILES_TRACE stderr for repeated +warm runs and fresh-home first runs of the same published artifact. Record runtime +version, RID, renderer, portal availability and cache state. Compare backdrop and +listing readiness separately, including closing the window during startup. + +Validation in this sandbox: single-node platform build passed with +`-p:NuGetAudit=false` (network auditing failed on the initial attempt); direct +runner: 1208 passed, 41 skipped, 13 failed (X11 display setup and native-directory +fixtures). Focused CursorSettingsResolverTests: 9 passed. `git diff --check` and +`bash -n scripts/linux/publish.sh` passed. Desktop build/run and published binary +verification were not performed; no measured latency improvement is claimed. diff --git a/scripts/linux/publish.sh b/scripts/linux/publish.sh index 1af7653b756e..e1a870742d65 100755 --- a/scripts/linux/publish.sh +++ b/scripts/linux/publish.sh @@ -30,7 +30,7 @@ rm -rf "$out" MSBUILDDISABLENODEREUSE=1 nice -n 19 dotnet publish "$root/src/Files.App/Files.App.csproj" \ -f net10.0-desktop -c Release -r "$rid" \ --self-contained "$self_contained" \ - -p:PublishTrimmed=false -p:PublishAot=false \ + -p:PublishTrimmed=false -p:PublishAot=false -p:PublishReadyToRun=true \ -nodeReuse:false -m:2 -o "$out" # This AOT executable is installed separately, root-owned, by native packages only. diff --git a/src/Files.App/App.xaml.cs b/src/Files.App/App.xaml.cs index ad549b14907b..4292965edef9 100644 --- a/src/Files.App/App.xaml.cs +++ b/src/Files.App/App.xaml.cs @@ -53,6 +53,7 @@ public static FlyoutBase? LastOpenedFlyout /// public App() { + using var trace = Files.Platform.Abstractions.Diagnostics.PerformanceTrace.Begin("startup.app-resources", true); InitializeComponent(); // Configure exception handlers diff --git a/src/Files.App/Files.App.csproj b/src/Files.App/Files.App.csproj index 53ae23608c90..2df01be3b764 100644 --- a/src/Files.App/Files.App.csproj +++ b/src/Files.App/Files.App.csproj @@ -30,7 +30,7 @@ true $(DefineConstants);DESKTOP - + true false diff --git a/src/Files.App/Helpers/Application/AppLifecycleHelper.cs b/src/Files.App/Helpers/Application/AppLifecycleHelper.cs index 050449cf4d22..9cd693844b82 100644 --- a/src/Files.App/Helpers/Application/AppLifecycleHelper.cs +++ b/src/Files.App/Helpers/Application/AppLifecycleHelper.cs @@ -302,6 +302,7 @@ public static void ConfigureSentry() /// Constructed on the UI thread by the caller (its ctor is UI-thread-only). public static IServiceProvider ConfigureHost(AppModel appModel) { + using var trace = Files.Platform.Abstractions.Diagnostics.PerformanceTrace.Begin("startup.configure-services", true); var services = new ServiceCollection(); var fileLoggerProvider = new FileLoggerProvider(Path.Combine(ApplicationData.Current.LocalFolder.Path, "debug.log")); @@ -414,6 +415,12 @@ public static IServiceProvider ConfigureHost(AppModel appModel) /// public static void SaveSessionTabs() { +#if DESKTOP + // Navigation can still be restoring tabs while the window closes. + if (!MainPageViewModel.StartupTabsLoadedTask.IsCompletedSuccessfully) + return; +#endif + var userSettingsService = Ioc.Default.GetRequiredService(); userSettingsService.GeneralSettingsService.LastSessionTabList = MainPageViewModel.AppInstances.DefaultIfEmpty().Select(tab => diff --git a/src/Files.App/MainWindow.xaml.cs b/src/Files.App/MainWindow.xaml.cs index 459b1832a040..711daecea395 100644 --- a/src/Files.App/MainWindow.xaml.cs +++ b/src/Files.App/MainWindow.xaml.cs @@ -31,6 +31,7 @@ public sealed partial class MainWindow : WindowEx public MainWindow() : base(416, 316) { + using var trace = Files.Platform.Abstractions.Diagnostics.PerformanceTrace.Begin("startup.window-resources", true); InitializeComponent(); ExtendsContentIntoTitleBar = UseClientSideDecorations; diff --git a/src/Files.App/Platforms/Desktop/App.Desktop.cs b/src/Files.App/Platforms/Desktop/App.Desktop.cs index 17287ef13e46..d68f80b46dfc 100644 --- a/src/Files.App/Platforms/Desktop/App.Desktop.cs +++ b/src/Files.App/Platforms/Desktop/App.Desktop.cs @@ -30,12 +30,9 @@ protected override void OnLaunched(LaunchActivatedEventArgs e) async Task ActivateAsync() { + using var startupTrace = Files.Platform.Abstractions.Diagnostics.PerformanceTrace.Begin("startup.activation", true); var serviceProvider = AppLifecycleHelper.ConfigureHost(AppModel); Ioc.Default.ConfigureServices(serviceProvider); - RootActionsHelper.InitializeTerminalAvailability(); - - if (AppLifecycleHelper.AppEnvironment is not AppEnvironment.Dev) - AppLifecycleHelper.ConfigureSentry(); // TODO: Replace with DI QuickAccessManager = Ioc.Default.GetRequiredService(); @@ -46,14 +43,23 @@ async Task ActivateAsync() AppModel = Ioc.Default.GetRequiredService(); MainWindow.Instance.RestorePlacement(Ioc.Default.GetRequiredService()); - MainWindow.Instance.Activate(); - UiResponsivenessProbe.Start(UiDispatcher!); - MainWindow.Instance.Closed += Window_Closed; MainWindow.Instance.Activated += Window_Activated; + using (var trace = Files.Platform.Abstractions.Diagnostics.PerformanceTrace.Begin("startup.first-backdrop", true)) + { + if (!await MainWindow.Instance.ActivateWithBackdropAsync()) + return; + } + + RootActionsHelper.InitializeTerminalAvailability(); + if (AppLifecycleHelper.AppEnvironment is not AppEnvironment.Dev) + AppLifecycleHelper.ConfigureSentry(); + UiResponsivenessProbe.Start(UiDispatcher!); + Logger.LogInformation("App launched (desktop)."); + using var navigationTrace = Files.Platform.Abstractions.Diagnostics.PerformanceTrace.Begin("startup.navigation-and-components", true); // Command line (paths, --select, -t, -n) of this process; later launches arrive through ISingleInstanceService var launchOptions = Files.App.Utils.CommandLine.DesktopCommandLine.Parse(Program.LaunchArguments, Environment.CurrentDirectory); if (launchOptions.IsEmpty) @@ -144,7 +150,7 @@ private async void Window_Closed(object sender, WindowEventArgs args) if (userSettingsService.GeneralSettingsService.ContinueLastSessionOnStartUp || userSettingsService.AppSettingsService.RestoreTabsOnStartup) AppLifecycleHelper.SaveSessionTabs(); - else + else if (MainPageViewModel.StartupTabsLoadedTask.IsCompletedSuccessfully) await commandManager.CloseAllTabs.ExecuteAsync(); // TODO: "leave app running" (background parking) is not supported on desktop yet diff --git a/src/Files.App/Platforms/Desktop/Program.cs b/src/Files.App/Platforms/Desktop/Program.cs index 66e85f69af7a..a68afaa35842 100644 --- a/src/Files.App/Platforms/Desktop/Program.cs +++ b/src/Files.App/Platforms/Desktop/Program.cs @@ -41,6 +41,7 @@ public static int Main(string[] args) { try { + using var trace = Files.Platform.Abstractions.Diagnostics.PerformanceTrace.Begin("startup.root-environment"); RootStartupEnvironment.Apply(); } catch (System.IO.IOException) @@ -76,6 +77,7 @@ public static int Main(string[] args) bool isPrimary; try { + using var trace = Files.Platform.Abstractions.Diagnostics.PerformanceTrace.Begin("startup.single-instance"); isPrimary = singleInstance.TryBecomePrimaryAsync(request).GetAwaiter().GetResult(); } catch (Exception ex) when (ex is System.IO.IOException or InvalidOperationException or System.Net.Sockets.SocketException or UnauthorizedAccessException) @@ -97,7 +99,8 @@ public static int Main(string[] args) var (renderingBackend, reason) = RenderingBackendSelector.Resolve(Environment.GetEnvironmentVariable, RenderingBackendSelector.HasGpuDevice); Console.Error.WriteLine($"[Files] Rendering backend: {renderingBackend} ({reason ?? "default"})"); - CursorSettingsResolver.ApplyToProcess(); + using (var trace = Files.Platform.Abstractions.Diagnostics.PerformanceTrace.Begin("startup.cursor")) + CursorSettingsResolver.ApplyToProcess(); var host = UnoPlatformHostBuilder.Create() .App(() => new App()) diff --git a/src/Files.App/Services/Settings/UserSettingsService.cs b/src/Files.App/Services/Settings/UserSettingsService.cs index ee01ea167e23..2c124ba0f1d5 100644 --- a/src/Files.App/Services/Settings/UserSettingsService.cs +++ b/src/Files.App/Services/Settings/UserSettingsService.cs @@ -91,6 +91,7 @@ public IAppSettingsService AppSettingsService public UserSettingsService() { + using var trace = Files.Platform.Abstractions.Diagnostics.PerformanceTrace.Begin("startup.user-settings", true); var settingsSerializer = new DefaultSettingsSerializer(message => App.Logger.LogWarning(message)); SettingsSerializer = settingsSerializer; diff --git a/src/Files.Platform.Linux/Theme/LinuxColorScheme.cs b/src/Files.Platform.Linux/Theme/LinuxColorScheme.cs index e25dafc4f9a5..d7e831684fda 100644 --- a/src/Files.Platform.Linux/Theme/LinuxColorScheme.cs +++ b/src/Files.Platform.Linux/Theme/LinuxColorScheme.cs @@ -25,6 +25,9 @@ public static class LinuxColorScheme private static DBusConnection? _connection; private static bool? _isDark; private static bool _watching; + private static bool _watchStarting; + private static long _updateVersion; + private static readonly SemaphoreSlim _connectionGate = new(1, 1); /// /// Raised (on a worker thread) when the system preference changes. @@ -41,8 +44,13 @@ public static class LinuxColorScheme /// public static bool Initialize() { + var version = NextUpdateVersion(); var value = Detect(); - _isDark = value; + lock (_gate) + { + if (version == _updateVersion) + _isDark = value; + } if (OverrideValue() is null) StartWatching(); @@ -62,6 +70,7 @@ public static bool Initialize() private static bool Detect() { + using var trace = Files.Platform.Abstractions.Diagnostics.PerformanceTrace.Begin("startup.theme-detection"); if (OverrideValue() is { } forced) return forced; @@ -73,8 +82,11 @@ private static bool Detect() { try { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - return Task.Run(ReadPortalAsync, cts.Token).Wait(TimeSpan.FromSeconds(2)) ? _lastRead : null; + return Task.Run(async () => + { + try { return await ReadPortalAsync().ConfigureAwait(false); } + catch (Exception) { return (uint?)null; } + }).WaitAsync(TimeSpan.FromMilliseconds(250)).GetAwaiter().GetResult(); } catch (Exception) { @@ -82,22 +94,19 @@ private static bool Detect() } } - private static uint? _lastRead; - - private static async Task ReadPortalAsync() + private static async Task ReadPortalAsync() { - _lastRead = null; var connection = await GetConnectionAsync(); if (connection is null) - return; + return null; - _lastRead = await connection.CallTracedAsync("dbus.theme", BuildReadMessage(connection), static (Message message, object? _) => + return await connection.CallTracedAsync("dbus.theme", BuildReadMessage(connection), static (Message message, object? _) => { var reader = message.GetBodyReader(); var outer = reader.ReadVariantValue(); var inner = outer.Type == VariantValueType.Variant ? outer.GetVariantValue() : outer; return inner.Type == VariantValueType.UInt32 ? (uint?)inner.GetUInt32() : null; - }, null); + }, null).WaitAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false); } private static MessageBuffer BuildReadMessage(DBusConnection connection) @@ -109,8 +118,9 @@ private static MessageBuffer BuildReadMessage(DBusConnection connection) return writer.CreateMessage(); } - private static async Task GetConnectionAsync() + private static async Task GetConnectionAsync(TimeSpan? timeout = null) { + await _connectionGate.WaitAsync().ConfigureAwait(false); try { var address = DBusAddress.Session; @@ -120,8 +130,7 @@ private static MessageBuffer BuildReadMessage(DBusConnection connection) var connection = _connection; if (connection is null) { - connection = new DBusConnection(address); - await connection.ConnectAsync(); + connection = await DBusSession.TryConnectAsync(address, timeout ?? TimeSpan.FromMilliseconds(250)).ConfigureAwait(false); _connection = connection; } @@ -131,62 +140,125 @@ private static MessageBuffer BuildReadMessage(DBusConnection connection) { return null; } + finally + { + _connectionGate.Release(); + } } private static void StartWatching() { lock (_gate) { - if (_watching) + if (_watching || _watchStarting) return; - _watching = true; + _watchStarting = true; + } + + _ = Task.Run(() => WatchAsync(SubscribeAsync, ReadPortalAsync, ResetConnectionAsync, Task.Delay)); + } + + private static async Task SubscribeAsync() + { + var connection = await GetConnectionAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false); + if (connection is null) + throw new IOException("Session bus is unavailable."); + + var rule = new MatchRule + { + Type = MessageType.Signal, + Sender = PortalService, + Path = PortalPath, + Interface = SettingsInterface, + Member = "SettingChanged", + }; + + await connection.AddMatchAsync(rule, static (Message message, object? _) => + { + var reader = message.GetBodyReader(); + var ns = reader.ReadString(); + var key = reader.ReadString(); + var outer = reader.ReadVariantValue(); + if (ns != "org.freedesktop.appearance" || key != "color-scheme") + return (uint?)null; + + return outer.Type == VariantValueType.UInt32 ? outer.GetUInt32() : (uint?)null; + }, static (Exception? ex, uint? value, object? _, object? _) => + { + if (ex is not null || value is null) + return; + + ApplyPortalValue(value.Value, NextUpdateVersion()); + }, null, null, false, ObserverFlags.None); + } + + private static async Task ResetConnectionAsync() + { + await _connectionGate.WaitAsync().ConfigureAwait(false); + try + { + _connection?.Dispose(); + _connection = null; } + finally { _connectionGate.Release(); } + } - _ = Task.Run(async () => + // Keep retry scheduling and reconciliation independent of the session bus and wall clock. + private static async Task WatchAsync(Func subscribe, Func> read, + Func resetConnection, Func wait) + { + var delay = TimeSpan.FromMilliseconds(250); + while (true) { try { - var connection = await GetConnectionAsync(); - if (connection is null) - return; - - var rule = new MatchRule + await subscribe().ConfigureAwait(false); + lock (_gate) { - Type = MessageType.Signal, - Sender = PortalService, - Path = PortalPath, - Interface = SettingsInterface, - Member = "SettingChanged", - }; - - await connection.AddMatchAsync(rule, static (Message message, object? _) => - { - var reader = message.GetBodyReader(); - var ns = reader.ReadString(); - var key = reader.ReadString(); - var outer = reader.ReadVariantValue(); - if (ns != "org.freedesktop.appearance" || key != "color-scheme") - return (uint?)null; - - return outer.Type == VariantValueType.UInt32 ? outer.GetUInt32() : (uint?)null; - }, static (Exception? ex, uint? value, object? _, object? _) => + _watching = true; + _watchStarting = false; + } + + // A later signal takes precedence over this snapshot. + var version = NextUpdateVersion(); + try { - if (ex is not null || value is null) - return; - - var dark = value is 1 or 2 ? value == 1 : FromGtkThemeEnv() ?? FromKdeGlobals() ?? FromGSettings() ?? false; - if (_isDark != dark) - { - _isDark = dark; - Changed?.Invoke(null, EventArgs.Empty); - } - }, null, null, false, ObserverFlags.None); + if (await read().ConfigureAwait(false) is { } current) + ApplyPortalValue(current, version); + } + catch (Exception) { } + return; } catch (Exception) { + await resetConnection().ConfigureAwait(false); } - }); + await wait(delay).ConfigureAwait(false); + delay = TimeSpan.FromMilliseconds(Math.Min(delay.TotalMilliseconds * 2, 30000)); + } + } + + private static long NextUpdateVersion() + { + lock (_gate) + return ++_updateVersion; + } + + private static void ApplyPortalValue(uint value, long version) + { + if (OverrideValue() is not null) + return; + + var dark = value is 1 or 2 ? value == 1 : FromGtkThemeEnv() ?? FromKdeGlobals() ?? FromGSettings() ?? false; + lock (_gate) + { + if (version != _updateVersion || _isDark == dark) + return; + + _isDark = dark; + } + Changed?.Invoke(null, EventArgs.Empty); } private static bool? FromGtkThemeEnv() @@ -246,7 +318,7 @@ await connection.AddMatchAsync(rule, static (Message message, object? _) => if (process is null) return null; - if (!process.WaitForExit(1500)) + if (!process.WaitForExit(250)) { process.Kill(); return null; diff --git a/src/Files.Platform.Linux/Windowing/CursorSettingsResolver.cs b/src/Files.Platform.Linux/Windowing/CursorSettingsResolver.cs index aee6432111c1..9488a53ef158 100644 --- a/src/Files.Platform.Linux/Windowing/CursorSettingsResolver.cs +++ b/src/Files.Platform.Linux/Windowing/CursorSettingsResolver.cs @@ -104,7 +104,7 @@ text[0] is not ('\'' or '"') || text[^1] != text[0]) if (process is null) return null; - using var timeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(1500)); + using var timeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(250)); var output = ReadBoundedAsync(process.StandardOutput, timeout.Token); var error = ReadBoundedAsync(process.StandardError, timeout.Token); try diff --git a/tests/Files.Platform.Tests/Theme/StartupRegressionTests.cs b/tests/Files.Platform.Tests/Theme/StartupRegressionTests.cs new file mode 100644 index 000000000000..70e2a4df2270 --- /dev/null +++ b/tests/Files.Platform.Tests/Theme/StartupRegressionTests.cs @@ -0,0 +1,168 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Platform.Linux.Theme; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.Reflection; +using System.Threading.Tasks; + +namespace Files.Platform.Tests.Theme +{ + [TestClass] + [DoNotParallelize] + public sealed class StartupRegressionTests + { + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task UnavailableBus_RetriesSlowConnectionAndDiscardsStaleReconciliation(bool sendNewerSignal) + { + var type = typeof(LinuxColorScheme); + const BindingFlags flags = BindingFlags.NonPublic | BindingFlags.Static; + var fields = new[] { "_isDark", "_watching", "_watchStarting", "_updateVersion" }; + var saved = Array.ConvertAll(fields, name => type.GetField(name, flags)!.GetValue(null)); + var forced = Environment.GetEnvironmentVariable("FILES_COLOR_SCHEME"); + var retry = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var connecting = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var connected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var snapshot = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var reading = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var attempts = 0; + var resets = 0; + Task? watching = null; + try + { + Environment.SetEnvironmentVariable("FILES_COLOR_SCHEME", null); + type.GetField("_isDark", flags)!.SetValue(null, false); + type.GetField("_watching", flags)!.SetValue(null, false); + type.GetField("_watchStarting", flags)!.SetValue(null, true); + Func subscribe = () => + { + if (++attempts == 1) + return Task.FromException(new IOException("Unavailable test bus")); + connecting.TrySetResult(); + return connected.Task; + }; + Func> read = () => { reading.TrySetResult(); return snapshot.Task; }; + Func reset = () => { resets++; return Task.CompletedTask; }; + Func wait = delay => + { + Assert.AreEqual(TimeSpan.FromMilliseconds(250), delay); + return retry.Task; + }; + watching = (Task)type.GetMethod("WatchAsync", flags)!.Invoke(null, [subscribe, read, reset, wait])!; + Assert.AreEqual(1, attempts); + Assert.AreEqual(1, resets); + Assert.IsFalse((bool)type.GetField("_watching", flags)!.GetValue(null)!); + Assert.IsFalse(LinuxColorScheme.IsDark); + retry.SetResult(); + await connecting.Task.WaitAsync(TimeSpan.FromSeconds(2)); + Assert.IsFalse(watching.IsCompleted, "A slow connection must remain pending, without another attempt."); + Assert.IsFalse((bool)type.GetField("_watching", flags)!.GetValue(null)!); + connected.SetResult(); + await reading.Task.WaitAsync(TimeSpan.FromSeconds(2)); + Assert.AreEqual(2, attempts); + Assert.IsTrue((bool)type.GetField("_watching", flags)!.GetValue(null)!); + if (sendNewerSignal) + { + var version = type.GetMethod("NextUpdateVersion", flags)!.Invoke(null, null); + type.GetMethod("ApplyPortalValue", flags)!.Invoke(null, [1u, version]); + } + snapshot.SetResult(sendNewerSignal ? 2u : 1u); + await watching.WaitAsync(TimeSpan.FromSeconds(2)); + Assert.IsTrue(LinuxColorScheme.IsDark, "Reconcile the read unless a newer signal superseded it."); + } + finally + { + retry.TrySetResult(); + connected.TrySetResult(); + snapshot.TrySetResult(null); + if (watching is not null) + await watching.WaitAsync(TimeSpan.FromSeconds(2)); + for (var i = 0; i < fields.Length; i++) + type.GetField(fields[i], flags)!.SetValue(null, saved[i]); + Environment.SetEnvironmentVariable("FILES_COLOR_SCHEME", forced); + } + } + + [TestMethod] + public async Task UnavailableBus_RetryBackoffIsBounded() + { + var delays = new System.Collections.Generic.List(); + var attempts = 0; + var resets = 0; + Func subscribe = () => + { + attempts++; + return Task.FromException(new IOException("Unavailable test bus")); + }; + Func> read = () => throw new AssertFailedException("Do not read before subscribing."); + Func reset = () => { resets++; return Task.CompletedTask; }; + Func wait = delay => + { + delays.Add(delay); + return delays.Count == 10 ? Task.FromCanceled(new System.Threading.CancellationToken(true)) : Task.CompletedTask; + }; + var watching = (Task)typeof(LinuxColorScheme).GetMethod("WatchAsync", BindingFlags.NonPublic | BindingFlags.Static)! + .Invoke(null, [subscribe, read, reset, wait])!; + try + { + await watching; + Assert.Fail("The fake clock must stop the retry loop."); + } + catch (OperationCanceledException) { } + Assert.AreEqual(10, attempts); + Assert.AreEqual(attempts, resets); + for (var i = 0; i < delays.Count; i++) + Assert.AreEqual(TimeSpan.FromMilliseconds(Math.Min(250 * Math.Pow(2, i), 30000)), delays[i]); + } + + [TestMethod] + public void DelayedPortalResult_DoesNotOverwriteNewerUpdateEvenWhenSignalKeepsSameTheme() + { + var type = typeof(LinuxColorScheme); + var version = type.GetField("_updateVersion", BindingFlags.NonPublic | BindingFlags.Static)!; + var preference = type.GetField("_isDark", BindingFlags.NonPublic | BindingFlags.Static)!; + var apply = type.GetMethod("ApplyPortalValue", BindingFlags.NonPublic | BindingFlags.Static)!; + var savedVersion = version.GetValue(null); + var savedPreference = preference.GetValue(null); + var forced = Environment.GetEnvironmentVariable("FILES_COLOR_SCHEME"); + try + { + Environment.SetEnvironmentVariable("FILES_COLOR_SCHEME", null); + preference.SetValue(null, true); + version.SetValue(null, 2L); + apply.Invoke(null, [1u, 2L]); // New signal, including a no-op update. + apply.Invoke(null, [2u, 1L]); // Delayed read captured before the signal. + Assert.IsTrue(LinuxColorScheme.IsDark); + version.SetValue(null, 3L); + apply.Invoke(null, [2u, 3L]); + Assert.IsFalse(LinuxColorScheme.IsDark, "A current update must still be applied."); + } + finally + { + version.SetValue(null, savedVersion); + preference.SetValue(null, savedPreference); + Environment.SetEnvironmentVariable("FILES_COLOR_SCHEME", forced); + } + } + + [TestMethod] + public void SessionPersistence_IsGuardedBeforeReadingOrWritingSettings() + { + // The desktop app cannot be loaded by the platform runner; verify its integration boundary. + var root = new DirectoryInfo(AppContext.BaseDirectory); + while (root is not null && !File.Exists(System.IO.Path.Combine(root.FullName, "src/Files.App/Platforms/Desktop/App.Desktop.cs"))) + root = root.Parent; + Assert.IsNotNull(root, "Repository source is required for the desktop integration check."); + var source = File.ReadAllText(System.IO.Path.Combine(root.FullName, "src/Files.App/Helpers/Application/AppLifecycleHelper.cs")); + var method = source[source.IndexOf("public static void SaveSessionTabs()", StringComparison.Ordinal)..]; + var settings = method.IndexOf("var userSettingsService", StringComparison.Ordinal); + var guard = method.IndexOf("if (!MainPageViewModel.StartupTabsLoadedTask.IsCompletedSuccessfully)", StringComparison.Ordinal); + Assert.IsTrue(guard >= 0 && guard < settings, "Closing before restoration must preserve the saved session."); + Assert.IsTrue(method[guard..settings].Contains("return;", StringComparison.Ordinal)); + } + } +}