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));
+ }
+ }
+}