From 64e62985b29e6a30de068c2c90d3bc22f6538bf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Mon, 12 Oct 2026 18:28:13 +0300 Subject: [PATCH 1/4] Reduce startup waits and defer work until the first backdrop render Co-Authored-By: Codex Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_016FAtbHHLQSiJiYcw2vnKiq --- .../linux-port/investigations/startup-time.md | 130 ++++++++++++++++++ scripts/linux/publish.sh | 2 +- src/Files.App/App.xaml.cs | 1 + src/Files.App/Files.App.csproj | 2 +- .../Helpers/Application/AppLifecycleHelper.cs | 1 + src/Files.App/MainWindow.xaml.cs | 1 + .../Platforms/Desktop/App.Desktop.cs | 20 ++- src/Files.App/Platforms/Desktop/Program.cs | 5 +- .../Services/Settings/UserSettingsService.cs | 1 + .../Theme/LinuxColorScheme.cs | 24 ++-- .../Windowing/CursorSettingsResolver.cs | 2 +- 11 files changed, 166 insertions(+), 23 deletions(-) create mode 100644 docs/linux-port/investigations/startup-time.md 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..11ab029c5647 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")); 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..01bccec2d69a 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) diff --git a/src/Files.App/Platforms/Desktop/Program.cs b/src/Files.App/Platforms/Desktop/Program.cs index 5409b11730f1..25051e3cdd0e 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..8a92b52a37d2 100644 --- a/src/Files.Platform.Linux/Theme/LinuxColorScheme.cs +++ b/src/Files.Platform.Linux/Theme/LinuxColorScheme.cs @@ -62,6 +62,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 +74,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 +86,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.FromMilliseconds(250)).ConfigureAwait(false); } private static MessageBuffer BuildReadMessage(DBusConnection connection) @@ -120,8 +121,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, TimeSpan.FromMilliseconds(250)).ConfigureAwait(false); _connection = connection; } @@ -246,7 +246,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 fcd3a81ebb00..0b14746110da 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 From 4a3c815b3152a88d3ba7694bdcf806a08d6368fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Mon, 12 Oct 2026 23:58:30 +0300 Subject: [PATCH 2/4] Address review findings Co-Authored-By: Codex Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_016FAtbHHLQSiJiYcw2vnKiq --- .../Helpers/Application/AppLifecycleHelper.cs | 6 ++ .../Platforms/Desktop/App.Desktop.cs | 2 +- .../Theme/LinuxColorScheme.cs | 26 ++++-- .../Theme/StartupRegressionTests.cs | 88 +++++++++++++++++++ 4 files changed, 114 insertions(+), 8 deletions(-) create mode 100644 tests/Files.Platform.Tests/Theme/StartupRegressionTests.cs diff --git a/src/Files.App/Helpers/Application/AppLifecycleHelper.cs b/src/Files.App/Helpers/Application/AppLifecycleHelper.cs index 11ab029c5647..9cd693844b82 100644 --- a/src/Files.App/Helpers/Application/AppLifecycleHelper.cs +++ b/src/Files.App/Helpers/Application/AppLifecycleHelper.cs @@ -415,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/Platforms/Desktop/App.Desktop.cs b/src/Files.App/Platforms/Desktop/App.Desktop.cs index 01bccec2d69a..d68f80b46dfc 100644 --- a/src/Files.App/Platforms/Desktop/App.Desktop.cs +++ b/src/Files.App/Platforms/Desktop/App.Desktop.cs @@ -150,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.Platform.Linux/Theme/LinuxColorScheme.cs b/src/Files.Platform.Linux/Theme/LinuxColorScheme.cs index 8a92b52a37d2..46e145fd4c6b 100644 --- a/src/Files.Platform.Linux/Theme/LinuxColorScheme.cs +++ b/src/Files.Platform.Linux/Theme/LinuxColorScheme.cs @@ -98,7 +98,7 @@ private static bool Detect() var outer = reader.ReadVariantValue(); var inner = outer.Type == VariantValueType.Variant ? outer.GetVariantValue() : outer; return inner.Type == VariantValueType.UInt32 ? (uint?)inner.GetUInt32() : null; - }, null).WaitAsync(TimeSpan.FromMilliseconds(250)).ConfigureAwait(false); + }, null).WaitAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false); } private static MessageBuffer BuildReadMessage(DBusConnection connection) @@ -175,13 +175,12 @@ await connection.AddMatchAsync(rule, static (Message message, object? _) => 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); - } + ApplyPortalValue(value.Value); }, null, null, false, ObserverFlags.None); + + // Reconcile preferences missed before subscription, including slow startup reads. + if (await ReadPortalAsync().ConfigureAwait(false) is { } current) + ApplyPortalValue(current); } catch (Exception) { @@ -189,6 +188,19 @@ await connection.AddMatchAsync(rule, static (Message message, object? _) => }); } + private static void ApplyPortalValue(uint value) + { + if (OverrideValue() is not 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); + } + } + private static bool? FromGtkThemeEnv() { var theme = Environment.GetEnvironmentVariable("GTK_THEME"); diff --git a/tests/Files.Platform.Tests/Theme/StartupRegressionTests.cs b/tests/Files.Platform.Tests/Theme/StartupRegressionTests.cs new file mode 100644 index 000000000000..739b622d7f63 --- /dev/null +++ b/tests/Files.Platform.Tests/Theme/StartupRegressionTests.cs @@ -0,0 +1,88 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Platform.Linux.DBus; +using Files.Platform.Linux.Theme; +using Files.Platform.Tests.SystemIntegration; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.Threading.Tasks; +using Tmds.DBus.Protocol; + +namespace Files.Platform.Tests.Theme +{ + [TestClass] + [DoNotParallelize] + public sealed class StartupRegressionTests + { + [TestMethod] + public async Task SlowPortal_ReconcilesDarkPreferenceWithoutASettingChangedSignal() + { + using var bus = PrivateBus.Start(); + using var server = await DBusSession.TryConnectAsync(bus.Address, TimeSpan.FromSeconds(2)) + ?? throw new AssertFailedException("no private bus"); + server.AddMethodHandler(new SlowSettingsServer()); + Assert.IsTrue(await server.TryRequestNameAsync("org.freedesktop.portal.Desktop", RequestNameOptions.None)); + + var address = Environment.GetEnvironmentVariable("DBUS_SESSION_BUS_ADDRESS"); + var forced = Environment.GetEnvironmentVariable("FILES_COLOR_SCHEME"); + var gtk = Environment.GetEnvironmentVariable("GTK_THEME"); + var changed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + void OnChanged(object? sender, EventArgs args) => changed.TrySetResult(); + LinuxColorScheme.Changed += OnChanged; + try + { + Environment.SetEnvironmentVariable("DBUS_SESSION_BUS_ADDRESS", bus.Address); + Environment.SetEnvironmentVariable("FILES_COLOR_SCHEME", null); + Environment.SetEnvironmentVariable("GTK_THEME", "Adwaita:light"); + Assert.IsFalse(LinuxColorScheme.Initialize(), "The startup budget must return the local fallback."); + await changed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.IsTrue(LinuxColorScheme.IsDark, "The delayed portal preference must replace the fallback."); + } + finally + { + LinuxColorScheme.Changed -= OnChanged; + Environment.SetEnvironmentVariable("DBUS_SESSION_BUS_ADDRESS", address); + Environment.SetEnvironmentVariable("FILES_COLOR_SCHEME", forced); + Environment.SetEnvironmentVariable("GTK_THEME", gtk); + } + } + + private sealed class SlowSettingsServer : IPathMethodHandler + { + public string Path => "/org/freedesktop/portal/desktop"; + public bool HandlesChildPaths => false; + + public async ValueTask HandleMethodAsync(MethodContext context) + { + if (context.Request.MemberAsString != "Read") + { + context.ReplyUnknownMethodError(); + return; + } + + await Task.Delay(400); + using var writer = context.CreateReplyWriter("v"); + writer.WriteVariantUInt32(1); + context.Reply(writer.CreateMessage()); + } + } + + [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)); + } + } +} From 1d77b7bf0a5b5b5e0c17dedd1fe7f57163324d4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Tue, 13 Oct 2026 00:51:40 +0300 Subject: [PATCH 3/4] Fix slow theme watcher connections and stale portal reconciliation Co-Authored-By: Codex Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016FAtbHHLQSiJiYcw2vnKiq --- .../Theme/LinuxColorScheme.cs | 135 ++++++++++++------ .../Theme/StartupRegressionTests.cs | 116 ++++++++++++++- 2 files changed, 201 insertions(+), 50 deletions(-) diff --git a/src/Files.Platform.Linux/Theme/LinuxColorScheme.cs b/src/Files.Platform.Linux/Theme/LinuxColorScheme.cs index 46e145fd4c6b..a38f854efb52 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(); @@ -110,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; @@ -121,7 +130,7 @@ private static MessageBuffer BuildReadMessage(DBusConnection connection) var connection = _connection; if (connection is null) { - connection = await DBusSession.TryConnectAsync(address, TimeSpan.FromMilliseconds(250)).ConfigureAwait(false); + connection = await DBusSession.TryConnectAsync(address, timeout ?? TimeSpan.FromMilliseconds(250)).ConfigureAwait(false); _connection = connection; } @@ -131,74 +140,112 @@ 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(async () => { - try + var delay = TimeSpan.FromMilliseconds(250); + while (true) { - var connection = await GetConnectionAsync(); - if (connection is null) - return; - - var rule = new MatchRule - { - Type = MessageType.Signal, - Sender = PortalService, - Path = PortalPath, - Interface = SettingsInterface, - Member = "SettingChanged", - }; - - await connection.AddMatchAsync(rule, static (Message message, object? _) => + try { - 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? _) => + 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); + + lock (_gate) + { + _watching = true; + _watchStarting = false; + } + + // A later signal takes precedence over this snapshot. + var version = NextUpdateVersion(); + try + { + if (await ReadPortalAsync().ConfigureAwait(false) is { } current) + ApplyPortalValue(current, version); + } + catch (Exception) { } + return; + } + catch (Exception) { - if (ex is not null || value is null) - return; - - ApplyPortalValue(value.Value); - }, null, null, false, ObserverFlags.None); - - // Reconcile preferences missed before subscription, including slow startup reads. - if (await ReadPortalAsync().ConfigureAwait(false) is { } current) - ApplyPortalValue(current); - } - catch (Exception) - { + await _connectionGate.WaitAsync().ConfigureAwait(false); + try + { + _connection?.Dispose(); + _connection = null; + } + finally { _connectionGate.Release(); } + } + await Task.Delay(delay).ConfigureAwait(false); + delay = TimeSpan.FromMilliseconds(Math.Min(delay.TotalMilliseconds * 2, 30000)); } }); } - private static void ApplyPortalValue(uint value) + 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; - if (_isDark != dark) + lock (_gate) { + if (version != _updateVersion || _isDark == dark) + return; + _isDark = dark; - Changed?.Invoke(null, EventArgs.Empty); } + Changed?.Invoke(null, EventArgs.Empty); } private static bool? FromGtkThemeEnv() diff --git a/tests/Files.Platform.Tests/Theme/StartupRegressionTests.cs b/tests/Files.Platform.Tests/Theme/StartupRegressionTests.cs index 739b622d7f63..ed5ea91edf38 100644 --- a/tests/Files.Platform.Tests/Theme/StartupRegressionTests.cs +++ b/tests/Files.Platform.Tests/Theme/StartupRegressionTests.cs @@ -7,6 +7,9 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using System; using System.IO; +using System.Reflection; +using System.Net.Sockets; +using System.Threading; using System.Threading.Tasks; using Tmds.DBus.Protocol; @@ -17,40 +20,128 @@ namespace Files.Platform.Tests.Theme public sealed class StartupRegressionTests { [TestMethod] - public async Task SlowPortal_ReconcilesDarkPreferenceWithoutASettingChangedSignal() + [DataRow(false)] + [DataRow(true)] + public async Task UnavailableBus_RetriesSlowConnectionAndDiscardsStaleReconciliation(bool sendNewerSignal) { using var bus = PrivateBus.Start(); + await using var proxy = new SlowBusProxy(bus.Address); using var server = await DBusSession.TryConnectAsync(bus.Address, TimeSpan.FromSeconds(2)) ?? throw new AssertFailedException("no private bus"); - server.AddMethodHandler(new SlowSettingsServer()); + var settings = new SlowSettingsServer(server, sendNewerSignal); + server.AddMethodHandler(settings); Assert.IsTrue(await server.TryRequestNameAsync("org.freedesktop.portal.Desktop", RequestNameOptions.None)); var address = Environment.GetEnvironmentVariable("DBUS_SESSION_BUS_ADDRESS"); var forced = Environment.GetEnvironmentVariable("FILES_COLOR_SCHEME"); var gtk = Environment.GetEnvironmentVariable("GTK_THEME"); var changed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + settings.SignalObserved = changed.Task; void OnChanged(object? sender, EventArgs args) => changed.TrySetResult(); LinuxColorScheme.Changed += OnChanged; try { - Environment.SetEnvironmentVariable("DBUS_SESSION_BUS_ADDRESS", bus.Address); + Environment.SetEnvironmentVariable("DBUS_SESSION_BUS_ADDRESS", "unix:path=/nonexistent/linuxfiles-theme-bus"); Environment.SetEnvironmentVariable("FILES_COLOR_SCHEME", null); Environment.SetEnvironmentVariable("GTK_THEME", "Adwaita:light"); Assert.IsFalse(LinuxColorScheme.Initialize(), "The startup budget must return the local fallback."); + await Task.Delay(350); + Assert.IsFalse((bool)typeof(LinuxColorScheme).GetField("_watching", BindingFlags.NonPublic | BindingFlags.Static)!.GetValue(null)!, + "A failed connection must not mark the watcher subscribed."); + Environment.SetEnvironmentVariable("DBUS_SESSION_BUS_ADDRESS", proxy.Address); await changed.Task.WaitAsync(TimeSpan.FromSeconds(5)); - Assert.IsTrue(LinuxColorScheme.IsDark, "The delayed portal preference must replace the fallback."); + await settings.Replied.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Task.Delay(100); + Assert.IsTrue(LinuxColorScheme.IsDark, "The preference must reconcile unless a newer signal superseded the read."); } finally { LinuxColorScheme.Changed -= OnChanged; + var connectionField = typeof(LinuxColorScheme).GetField("_connection", BindingFlags.NonPublic | BindingFlags.Static)!; + (connectionField.GetValue(null) as IDisposable)?.Dispose(); + connectionField.SetValue(null, null); + typeof(LinuxColorScheme).GetField("_watching", BindingFlags.NonPublic | BindingFlags.Static)!.SetValue(null, false); Environment.SetEnvironmentVariable("DBUS_SESSION_BUS_ADDRESS", address); Environment.SetEnvironmentVariable("FILES_COLOR_SCHEME", forced); Environment.SetEnvironmentVariable("GTK_THEME", gtk); } } - private sealed class SlowSettingsServer : IPathMethodHandler + [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); + } + } + + private sealed class SlowBusProxy : IAsyncDisposable + { + private readonly Socket listener = new(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + private readonly CancellationTokenSource stop = new(); + private readonly string path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "theme-bus-" + Guid.NewGuid().ToString("N")); + private readonly Task pump; + public string Address => "unix:path=" + path; + + public SlowBusProxy(string target) + { + listener.Bind(new UnixDomainSocketEndPoint(path)); + listener.Listen(4); + pump = ForwardAsync(target.Split(',')[0]["unix:path=".Length..]); + } + + private async Task ForwardAsync(string target) + { + try + { + using var incoming = await listener.AcceptAsync(stop.Token); + // Authentication takes longer than the synchronous startup budget. + await Task.Delay(400, stop.Token); + using var outgoing = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + await outgoing.ConnectAsync(new UnixDomainSocketEndPoint(target), stop.Token); + using var input = new NetworkStream(incoming); + using var output = new NetworkStream(outgoing); + await Task.WhenAll(input.CopyToAsync(output, stop.Token), output.CopyToAsync(input, stop.Token)); + } + catch (OperationCanceledException) { } + } + + public async ValueTask DisposeAsync() + { + await stop.CancelAsync(); + await pump; + listener.Dispose(); + stop.Dispose(); + File.Delete(path); + } + } + + private sealed class SlowSettingsServer(DBusConnection connection, bool sendNewerSignal) : IPathMethodHandler + { + public Task SignalObserved { get; set; } = Task.CompletedTask; + public TaskCompletionSource Replied { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public string Path => "/org/freedesktop/portal/desktop"; public bool HandlesChildPaths => false; @@ -63,9 +154,22 @@ public async ValueTask HandleMethodAsync(MethodContext context) } await Task.Delay(400); + if (sendNewerSignal) + { + { + using var signal = connection.GetMessageWriter(); + signal.WriteSignalHeader(null, Path, "org.freedesktop.portal.Settings", "SettingChanged", "ssv"); + signal.WriteString("org.freedesktop.appearance"); + signal.WriteString("color-scheme"); + signal.WriteVariantUInt32(1); + connection.TrySendMessage(signal.CreateMessage()); + } + await SignalObserved.WaitAsync(TimeSpan.FromSeconds(5)); + } using var writer = context.CreateReplyWriter("v"); - writer.WriteVariantUInt32(1); + writer.WriteVariantUInt32(sendNewerSignal ? 2u : 1u); context.Reply(writer.CreateMessage()); + Replied.TrySetResult(); } } From d84164f105a6cde37e7c18efb062e56c935a65ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Tue, 13 Oct 2026 00:57:45 +0300 Subject: [PATCH 4/4] Make theme watcher retry tests deterministic Co-Authored-By: Codex Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016FAtbHHLQSiJiYcw2vnKiq --- .../Theme/LinuxColorScheme.cs | 135 ++++++------ .../Theme/StartupRegressionTests.cs | 202 ++++++++---------- 2 files changed, 163 insertions(+), 174 deletions(-) diff --git a/src/Files.Platform.Linux/Theme/LinuxColorScheme.cs b/src/Files.Platform.Linux/Theme/LinuxColorScheme.cs index a38f854efb52..d7e831684fda 100644 --- a/src/Files.Platform.Linux/Theme/LinuxColorScheme.cs +++ b/src/Files.Platform.Linux/Theme/LinuxColorScheme.cs @@ -156,74 +156,87 @@ private static void StartWatching() _watchStarting = true; } - _ = Task.Run(async () => + _ = 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(); } + } + + // 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) { - var delay = TimeSpan.FromMilliseconds(250); - while (true) + try { - try + await subscribe().ConfigureAwait(false); + lock (_gate) { - 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); - - lock (_gate) - { - _watching = true; - _watchStarting = false; - } - - // A later signal takes precedence over this snapshot. - var version = NextUpdateVersion(); - try - { - if (await ReadPortalAsync().ConfigureAwait(false) is { } current) - ApplyPortalValue(current, version); - } - catch (Exception) { } - return; + _watching = true; + _watchStarting = false; } - catch (Exception) + + // A later signal takes precedence over this snapshot. + var version = NextUpdateVersion(); + try { - await _connectionGate.WaitAsync().ConfigureAwait(false); - try - { - _connection?.Dispose(); - _connection = null; - } - finally { _connectionGate.Release(); } + if (await read().ConfigureAwait(false) is { } current) + ApplyPortalValue(current, version); } - await Task.Delay(delay).ConfigureAwait(false); - delay = TimeSpan.FromMilliseconds(Math.Min(delay.TotalMilliseconds * 2, 30000)); + 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() diff --git a/tests/Files.Platform.Tests/Theme/StartupRegressionTests.cs b/tests/Files.Platform.Tests/Theme/StartupRegressionTests.cs index ed5ea91edf38..70e2a4df2270 100644 --- a/tests/Files.Platform.Tests/Theme/StartupRegressionTests.cs +++ b/tests/Files.Platform.Tests/Theme/StartupRegressionTests.cs @@ -1,17 +1,12 @@ // Copyright (c) Files Community // Licensed under the MIT License. -using Files.Platform.Linux.DBus; using Files.Platform.Linux.Theme; -using Files.Platform.Tests.SystemIntegration; using Microsoft.VisualStudio.TestTools.UnitTesting; using System; using System.IO; using System.Reflection; -using System.Net.Sockets; -using System.Threading; using System.Threading.Tasks; -using Tmds.DBus.Protocol; namespace Files.Platform.Tests.Theme { @@ -24,49 +19,106 @@ public sealed class StartupRegressionTests [DataRow(true)] public async Task UnavailableBus_RetriesSlowConnectionAndDiscardsStaleReconciliation(bool sendNewerSignal) { - using var bus = PrivateBus.Start(); - await using var proxy = new SlowBusProxy(bus.Address); - using var server = await DBusSession.TryConnectAsync(bus.Address, TimeSpan.FromSeconds(2)) - ?? throw new AssertFailedException("no private bus"); - var settings = new SlowSettingsServer(server, sendNewerSignal); - server.AddMethodHandler(settings); - Assert.IsTrue(await server.TryRequestNameAsync("org.freedesktop.portal.Desktop", RequestNameOptions.None)); - - var address = Environment.GetEnvironmentVariable("DBUS_SESSION_BUS_ADDRESS"); + 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 gtk = Environment.GetEnvironmentVariable("GTK_THEME"); - var changed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - settings.SignalObserved = changed.Task; - void OnChanged(object? sender, EventArgs args) => changed.TrySetResult(); - LinuxColorScheme.Changed += OnChanged; + 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("DBUS_SESSION_BUS_ADDRESS", "unix:path=/nonexistent/linuxfiles-theme-bus"); Environment.SetEnvironmentVariable("FILES_COLOR_SCHEME", null); - Environment.SetEnvironmentVariable("GTK_THEME", "Adwaita:light"); - Assert.IsFalse(LinuxColorScheme.Initialize(), "The startup budget must return the local fallback."); - await Task.Delay(350); - Assert.IsFalse((bool)typeof(LinuxColorScheme).GetField("_watching", BindingFlags.NonPublic | BindingFlags.Static)!.GetValue(null)!, - "A failed connection must not mark the watcher subscribed."); - Environment.SetEnvironmentVariable("DBUS_SESSION_BUS_ADDRESS", proxy.Address); - await changed.Task.WaitAsync(TimeSpan.FromSeconds(5)); - await settings.Replied.Task.WaitAsync(TimeSpan.FromSeconds(5)); - await Task.Delay(100); - Assert.IsTrue(LinuxColorScheme.IsDark, "The preference must reconcile unless a newer signal superseded the read."); + 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 { - LinuxColorScheme.Changed -= OnChanged; - var connectionField = typeof(LinuxColorScheme).GetField("_connection", BindingFlags.NonPublic | BindingFlags.Static)!; - (connectionField.GetValue(null) as IDisposable)?.Dispose(); - connectionField.SetValue(null, null); - typeof(LinuxColorScheme).GetField("_watching", BindingFlags.NonPublic | BindingFlags.Static)!.SetValue(null, false); - Environment.SetEnvironmentVariable("DBUS_SESSION_BUS_ADDRESS", address); + 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); - Environment.SetEnvironmentVariable("GTK_THEME", gtk); } } + [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() { @@ -97,82 +149,6 @@ public void DelayedPortalResult_DoesNotOverwriteNewerUpdateEvenWhenSignalKeepsSa } } - private sealed class SlowBusProxy : IAsyncDisposable - { - private readonly Socket listener = new(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); - private readonly CancellationTokenSource stop = new(); - private readonly string path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "theme-bus-" + Guid.NewGuid().ToString("N")); - private readonly Task pump; - public string Address => "unix:path=" + path; - - public SlowBusProxy(string target) - { - listener.Bind(new UnixDomainSocketEndPoint(path)); - listener.Listen(4); - pump = ForwardAsync(target.Split(',')[0]["unix:path=".Length..]); - } - - private async Task ForwardAsync(string target) - { - try - { - using var incoming = await listener.AcceptAsync(stop.Token); - // Authentication takes longer than the synchronous startup budget. - await Task.Delay(400, stop.Token); - using var outgoing = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); - await outgoing.ConnectAsync(new UnixDomainSocketEndPoint(target), stop.Token); - using var input = new NetworkStream(incoming); - using var output = new NetworkStream(outgoing); - await Task.WhenAll(input.CopyToAsync(output, stop.Token), output.CopyToAsync(input, stop.Token)); - } - catch (OperationCanceledException) { } - } - - public async ValueTask DisposeAsync() - { - await stop.CancelAsync(); - await pump; - listener.Dispose(); - stop.Dispose(); - File.Delete(path); - } - } - - private sealed class SlowSettingsServer(DBusConnection connection, bool sendNewerSignal) : IPathMethodHandler - { - public Task SignalObserved { get; set; } = Task.CompletedTask; - public TaskCompletionSource Replied { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - public string Path => "/org/freedesktop/portal/desktop"; - public bool HandlesChildPaths => false; - - public async ValueTask HandleMethodAsync(MethodContext context) - { - if (context.Request.MemberAsString != "Read") - { - context.ReplyUnknownMethodError(); - return; - } - - await Task.Delay(400); - if (sendNewerSignal) - { - { - using var signal = connection.GetMessageWriter(); - signal.WriteSignalHeader(null, Path, "org.freedesktop.portal.Settings", "SettingChanged", "ssv"); - signal.WriteString("org.freedesktop.appearance"); - signal.WriteString("color-scheme"); - signal.WriteVariantUInt32(1); - connection.TrySendMessage(signal.CreateMessage()); - } - await SignalObserved.WaitAsync(TimeSpan.FromSeconds(5)); - } - using var writer = context.CreateReplyWriter("v"); - writer.WriteVariantUInt32(sendNewerSignal ? 2u : 1u); - context.Reply(writer.CreateMessage()); - Replied.TrySetResult(); - } - } - [TestMethod] public void SessionPersistence_IsGuardedBeforeReadingOrWritingSettings() {