diff --git a/src/WayfarerMobile.Core/Interfaces/ISettingsService.cs b/src/WayfarerMobile.Core/Interfaces/ISettingsService.cs
index a8442fd2..3fc0599c 100644
--- a/src/WayfarerMobile.Core/Interfaces/ISettingsService.cs
+++ b/src/WayfarerMobile.Core/Interfaces/ISettingsService.cs
@@ -292,6 +292,12 @@ public interface ISettingsService
#endregion
+ ///
+ /// Pre-loads secure settings from SecureStorage into memory cache.
+ /// Call this at app startup to avoid blocking on first access.
+ ///
+ Task PreloadSecureSettingsAsync();
+
///
/// Clears all settings (for logout/reset).
///
diff --git a/src/WayfarerMobile/App.xaml.cs b/src/WayfarerMobile/App.xaml.cs
index 0e993630..39a480e9 100644
--- a/src/WayfarerMobile/App.xaml.cs
+++ b/src/WayfarerMobile/App.xaml.cs
@@ -1,7 +1,5 @@
using Microsoft.Extensions.Logging;
using WayfarerMobile.Core.Interfaces;
-using WayfarerMobile.Core.Models;
-using WayfarerMobile.Data.Repositories;
using WayfarerMobile.Helpers;
using WayfarerMobile.Interfaces;
using WayfarerMobile.Services;
@@ -171,53 +169,20 @@ private void InitializeExceptionHandler()
///
/// Starts background services for the application.
+ /// Sync pipeline (settings, drain, timeline, delegates) is handled by
+ /// which is idempotent
+ /// and also callable from the headless LocationTrackingService after process kill.
///
private void StartBackgroundServices()
{
try
{
- // Pre-load secure settings to avoid blocking SecureStorage calls later
- // This must happen early to prevent deadlocks on Android when API calls access ServerUrl/ApiToken
- var settings = _serviceProvider.GetService() as SettingsService;
- if (settings != null)
- {
- _ = settings.PreloadSecureSettingsAsync();
- _logger.LogDebug("Secure settings preload started");
- }
-
- // Start queue drain service (offline queue sync via check-in endpoint)
- var queueDrainService = _serviceProvider.GetService();
- SafeFireAndForget(queueDrainService?.StartAsync(), "QueueDrainService");
- _logger.LogDebug("Queue drain service started");
-
- // Start timeline sync service (offline mutation sync)
- var timelineSyncService = _serviceProvider.GetService();
- SafeFireAndForget(timelineSyncService?.StartAsync(), "TimelineSyncService");
- _logger.LogDebug("Timeline sync service started");
-
- // Wire up drain loop starter for background location services
- // Both services piggyback on location wakeups for background sync
- if (queueDrainService != null)
- {
- Action drainLoopStarter = () =>
- {
- queueDrainService.StartDrainLoop();
- timelineSyncService?.StartDrainLoop(); // Piggyback on location wakeups
- };
- Func isRunningChecker = () => queueDrainService.IsDrainLoopRunning;
-
-#if ANDROID
- WayfarerMobile.Platforms.Android.Services.LocationTrackingService.SetDrainLoopStarter(drainLoopStarter, isRunningChecker);
-#elif IOS
- WayfarerMobile.Platforms.iOS.Services.LocationTrackingService.SetDrainLoopStarter(drainLoopStarter, isRunningChecker);
-#endif
- _logger.LogDebug("Drain loop starter wired to location services (includes timeline sync)");
- }
-
- // Initialize local timeline storage service (subscribes to location events)
- var timelineStorageService = _serviceProvider.GetService();
- SafeFireAndForget(timelineStorageService?.InitializeAsync(), "LocalTimelineStorageService");
- _logger.LogDebug("Local timeline storage service initialization started");
+ // Bootstrap the entire sync pipeline (settings preload, drain services,
+ // timeline storage, location delegates) via shared idempotent bootstrapper.
+ // This same method is called from LocationTrackingService on headless restart.
+ SafeFireAndForget(
+ LocationPipelineWiring.EnsureBootstrappedAsync(_serviceProvider),
+ "LocationPipelineWiring");
// Note: Settings sync is handled by SettingsSyncService, triggered opportunistically
// in AppLifecycleService.OnResumingAsync() with a 6-hour minimum interval
@@ -231,10 +196,6 @@ private void StartBackgroundServices()
SafeFireAndForget(visitNotificationService?.StartAsync(), "VisitNotificationService");
_logger.LogDebug("Visit notification service initialization started");
- // Wire up location tracking delegates for online/offline path decision
- WireLocationTrackingDelegates(timelineStorageService);
- _logger.LogDebug("Location tracking delegates wired");
-
// Note: Location tracking service start is handled by OnWindowActivatedForServiceStart
// to ensure deterministic timing after UI is fully initialized.
}
@@ -244,117 +205,6 @@ private void StartBackgroundServices()
}
}
- ///
- /// Wires up the location tracking delegates for online/offline path decision.
- /// Called early during startup to ensure delegates are set before location service starts.
- ///
- /// The local timeline storage service for updating timeline.
- private void WireLocationTrackingDelegates(LocalTimelineStorageService? timelineStorageService)
- {
- var apiClient = _serviceProvider.GetService();
- var locationQueue = _serviceProvider.GetService();
-
- // Online submit delegate: Submit directly to server via log-location endpoint
- // Return semantics:
- // - Returns serverId: Server accepted the location
- // - Returns null: Server explicitly skipped (threshold not met) - don't queue
- // - Throws exception: Network or API failure - triggers offline queue fallback
- Func> onlineSubmit = async location =>
- {
- if (apiClient == null)
- throw new InvalidOperationException("API client not available");
-
- // Early connectivity check - avoids timeout delays when completely offline
- // Only block on NetworkAccess.None (no network interface at all).
- // Allow Local/ConstrainedInternet to attempt the call for LAN-only server deployments.
- // The IsTransient check below handles actual network failures.
- if (Connectivity.Current.NetworkAccess == NetworkAccess.None)
- throw new HttpRequestException("No network connectivity");
-
- // Convert to API request model with metadata
- var request = new LocationLogRequest
- {
- Latitude = location.Latitude,
- Longitude = location.Longitude,
- Accuracy = location.Accuracy,
- Altitude = location.Altitude,
- Speed = location.Speed,
- Timestamp = location.Timestamp,
- Provider = location.Provider,
- Bearing = location.Bearing,
- // Metadata fields - captured at submission time
- Source = "mobile-log",
- IsUserInvoked = false,
- AppVersion = DeviceMetadataHelper.GetAppVersion(),
- AppBuild = DeviceMetadataHelper.GetAppBuild(),
- DeviceModel = DeviceMetadataHelper.GetDeviceModel(),
- OsVersion = DeviceMetadataHelper.GetOsVersion(),
- BatteryLevel = DeviceMetadataHelper.GetBatteryLevel(),
- IsCharging = DeviceMetadataHelper.GetIsCharging()
- };
-
- // Call the log-location endpoint (server is authoritative)
- var result = await apiClient.LogLocationAsync(request, idempotencyKey: null);
-
- // Case 1: Server accepted or skipped - don't queue
- // Note: log-location API may return just { "success": true } without locationId
- if (result.Success)
- {
- // Only update local timeline if server returned a locationId (not skipped)
- if (!result.Skipped && result.LocationId.HasValue && timelineStorageService != null)
- {
- await timelineStorageService.AddAcceptedLocationAsync(location, result.LocationId.Value);
- }
- // Return locationId if available, null otherwise (either skipped or accepted without ID)
- return result.LocationId;
- }
-
- // Case 2: Transient failure - throw to trigger offline queue
- // Check both IsTransient flag AND status code, since ApiClient may not set
- // IsTransient for HTTP status failures (they come through with IsTransient=false)
- // Transient codes: 408 (Request Timeout), 429 (Too Many Requests), 5xx (Server Error)
- // QueueDrainService handles these appropriately with retry logic.
- var isTransientStatusCode = result.StatusCode.HasValue &&
- (result.StatusCode == 408 || result.StatusCode == 429 || result.StatusCode >= 500);
-
- if (result.IsTransient || isTransientStatusCode)
- throw new HttpRequestException($"Transient failure: {result.Message}");
-
- // Case 3: Permanent API failure (4xx client errors) - return null, don't queue
- // These won't succeed on retry (bad request, unauthorized, etc.) and queueing them
- // creates pending timeline entries that never clear since QueueDrainService's 4xx
- // handling doesn't emit LocationSkipped events.
- return null;
- };
-
- // Offline queue delegate: Queue for background sync
- Func> offlineQueue = async location =>
- {
- if (locationQueue == null)
- throw new InvalidOperationException("Location queue not available");
-
- // Queue with isUserInvoked=false (background location)
- var queuedId = await locationQueue.QueueLocationAsync(location, isUserInvoked: false);
-
- // Add pending entry to local timeline (will be updated when queue drains)
- if (timelineStorageService != null)
- {
- await timelineStorageService.AddPendingLocationAsync(location, queuedId);
- }
-
- return queuedId;
- };
-
- // Wire up delegates to platform-specific location services
-#if ANDROID
- WayfarerMobile.Platforms.Android.Services.LocationTrackingService.OnlineSubmitDelegate = onlineSubmit;
- WayfarerMobile.Platforms.Android.Services.LocationTrackingService.OfflineQueueDelegate = offlineQueue;
-#elif IOS
- WayfarerMobile.Platforms.iOS.Services.LocationTrackingService.OnlineSubmitDelegate = onlineSubmit;
- WayfarerMobile.Platforms.iOS.Services.LocationTrackingService.OfflineQueueDelegate = offlineQueue;
-#endif
- }
-
///
/// Starts the location tracking service if permissions are granted.
/// The service runs 24/7 with context switching between high/normal performance modes.
diff --git a/src/WayfarerMobile/Platforms/Android/Services/LocationTrackingService.cs b/src/WayfarerMobile/Platforms/Android/Services/LocationTrackingService.cs
index 34a7e224..ec32fbf5 100644
--- a/src/WayfarerMobile/Platforms/Android/Services/LocationTrackingService.cs
+++ b/src/WayfarerMobile/Platforms/Android/Services/LocationTrackingService.cs
@@ -1337,13 +1337,18 @@ private async void LogLocationToQueue(LocationData location)
return;
}
- // FALLBACK: Direct database queue (no delegates wired)
+ // FALLBACK: Direct database queue (no delegates wired — headless restart)
+ // Trigger sync pipeline bootstrap so future locations use wired delegates.
+ // Fire-and-forget: the current location still goes through the bare DB fallback.
+ _ = Task.Run(async () => await LocationPipelineWiring.EnsureBootstrappedAsync(
+ IPlatformApplication.Current?.Services));
+
if (_databaseService != null)
{
// Get queue limit from settings (Preferences accessed directly since platform service can't use DI)
var maxQueuedLocations = Preferences.Get(SettingsService.QueueLimitMaxLocationsKey, 25000);
await _databaseService.QueueLocationAsync(location, maxQueuedLocations);
- Log.Debug(LogTag, $"Queued via DatabaseService: {location}");
+ Log.Debug(LogTag, $"Queued via DatabaseService (headless fallback): {location}");
// Notify that location was queued - used by LocalTimelineStorageService
// to store with correct coordinates (may differ from broadcast when using best-wake-sample)
diff --git a/src/WayfarerMobile/Services/LocalTimelineStorageService.cs b/src/WayfarerMobile/Services/LocalTimelineStorageService.cs
index 5b6413f3..eac1e455 100644
--- a/src/WayfarerMobile/Services/LocalTimelineStorageService.cs
+++ b/src/WayfarerMobile/Services/LocalTimelineStorageService.cs
@@ -32,6 +32,7 @@ public class LocalTimelineStorageService : IDisposable
private readonly ILogger _logger;
private bool _isInitialized;
private bool _disposed;
+ private int _eventsSubscribed; // Interlocked guard for SubscribeToEvents idempotency
///
/// Time window for backfill/reconciliation operations (days).
@@ -340,6 +341,14 @@ private async Task BackfillMissedQueueEntriesAsync()
///
private void SubscribeToEvents()
{
+ // Atomic guard: prevent duplicate event subscriptions across concurrent/retry calls.
+ // Duplicate subscriptions would produce duplicate timeline entries per location event.
+ if (Interlocked.CompareExchange(ref _eventsSubscribed, 1, 0) != 0)
+ {
+ _logger.LogDebug("Event subscriptions already active, skipping");
+ return;
+ }
+
// Subscribe to LocationQueued (not LocationReceived) to ensure we store
// the same coordinates that will be synced. On Android, these may differ
// due to best-wake-sample optimization.
diff --git a/src/WayfarerMobile/Services/LocationPipelineWiring.cs b/src/WayfarerMobile/Services/LocationPipelineWiring.cs
new file mode 100644
index 00000000..6a4964fc
--- /dev/null
+++ b/src/WayfarerMobile/Services/LocationPipelineWiring.cs
@@ -0,0 +1,267 @@
+using Microsoft.Extensions.Logging;
+using WayfarerMobile.Core.Interfaces;
+using WayfarerMobile.Core.Models;
+using WayfarerMobile.Data.Repositories;
+using WayfarerMobile.Helpers;
+
+namespace WayfarerMobile.Services;
+
+///
+/// Sole orchestrator of the location sync pipeline bootstrap.
+/// Ensures delegates, drain services, and timeline storage are wired
+/// exactly once, whether bootstrapped from App.xaml.cs (normal MAUI start)
+/// or from LocationTrackingService (headless restart after process kill).
+///
+///
+///
+/// Design invariants:
+///
+///
+/// - Guarded by Interlocked.CompareExchange - concurrent callers are safe.
+/// - On failure the guard resets, allowing retry on the next location fix.
+/// This is safe because all downstream services (R3) are individually idempotent.
+/// - LocationTrackingService does not know WHAT gets bootstrapped - it calls one method.
+///
+///
+public static class LocationPipelineWiring
+{
+ private static int _bootstrapGuard;
+
+ ///
+ /// Resets the bootstrap guard to allow re-entry. For test use only.
+ ///
+ internal static void ResetForTesting()
+ {
+ Interlocked.Exchange(ref _bootstrapGuard, 0);
+ }
+
+ ///
+ /// Bootstraps the sync pipeline idempotently. Safe to call from multiple threads.
+ ///
+ ///
+ /// DI service provider. If null, logs a warning and returns (no retry loop -
+ /// Android guarantees Application.onCreate() completes before Service.onCreate(),
+ /// so DI is available).
+ ///
+ public static async Task EnsureBootstrappedAsync(IServiceProvider? serviceProvider)
+ {
+ if (serviceProvider == null)
+ {
+#if ANDROID
+ Android.Util.Log.Warn("WayfarerLocation", "LocationPipelineWiring: serviceProvider is null, skipping bootstrap");
+#endif
+ return;
+ }
+
+ // Atomic guard: only one caller enters bootstrap
+ if (Interlocked.CompareExchange(ref _bootstrapGuard, 1, 0) != 0)
+ return;
+
+ // Resolve logger from DI (available once we have a serviceProvider)
+ var logger = serviceProvider.GetService()?.CreateLogger(typeof(LocationPipelineWiring).FullName!);
+
+ var delegatesWired = false;
+ var drainStarted = false;
+
+ try
+ {
+ // 1. Resolve DI services
+ var settingsService = serviceProvider.GetService();
+ var queueDrainService = serviceProvider.GetService();
+ var timelineSyncService = serviceProvider.GetService();
+ var apiClient = serviceProvider.GetService();
+ var locationQueue = serviceProvider.GetService();
+ var timelineStorageService = serviceProvider.GetService();
+
+ // 2. Preload secure settings (prevents deadlocks on Android SecureStorage)
+ if (settingsService != null)
+ {
+ await settingsService.PreloadSecureSettingsAsync();
+ }
+
+ // 3. Start QueueDrainService and TimelineSyncService (both idempotent via R3 guards)
+ if (queueDrainService != null)
+ {
+ await queueDrainService.StartAsync();
+ }
+
+ if (timelineSyncService != null)
+ {
+ await timelineSyncService.StartAsync();
+ }
+
+ drainStarted = true;
+
+ // 4. Wire drain-loop starter delegate
+ if (queueDrainService != null)
+ {
+ Action drainLoopStarter = () =>
+ {
+ queueDrainService.StartDrainLoop();
+ timelineSyncService?.StartDrainLoop(); // Piggyback on location wakeups
+ };
+ // Check both services: skip only if BOTH drain loops are already running.
+ // Each service has its own Interlocked guard, so invoking the starter
+ // when one is idle and the other is running is safe and desired.
+ Func isRunningChecker = () =>
+ queueDrainService.IsDrainLoopRunning &&
+ (timelineSyncService?.IsDrainLoopRunning ?? true);
+
+#if ANDROID
+ Platforms.Android.Services.LocationTrackingService.SetDrainLoopStarter(drainLoopStarter, isRunningChecker);
+#elif IOS
+ Platforms.iOS.Services.LocationTrackingService.SetDrainLoopStarter(drainLoopStarter, isRunningChecker);
+#endif
+ }
+
+ // 5. Initialize LocalTimelineStorageService (idempotent via R3 guard on SubscribeToEvents)
+ if (timelineStorageService != null)
+ {
+ await timelineStorageService.InitializeAsync();
+ }
+
+ // 6. Build and set OnlineSubmitDelegate and OfflineQueueDelegate
+ if (apiClient != null && locationQueue != null)
+ {
+ WireLocationDelegates(apiClient, locationQueue, timelineStorageService);
+ delegatesWired = true;
+ }
+
+ logger?.LogInformation(
+ "LocationPipelineWiring: bootstrap=success delegates={DelegatesWired} drain={DrainStarted}",
+ delegatesWired, drainStarted);
+
+ // If delegates weren't wired (e.g. IApiClient or ILocationQueueRepository resolved to null),
+ // reset the guard so the next location fix can retry bootstrap.
+ if (!delegatesWired)
+ {
+ Interlocked.Exchange(ref _bootstrapGuard, 0);
+ logger?.LogWarning(
+ "LocationPipelineWiring: incomplete bootstrap (delegates not wired), will retry on next location fix");
+ }
+ }
+ catch (Exception ex)
+ {
+ // Reset guard to allow retry on next location fix.
+ // Safe because all steps called above are individually idempotent (R3).
+ Interlocked.Exchange(ref _bootstrapGuard, 0);
+
+ logger?.LogError(ex,
+ "LocationPipelineWiring: bootstrap=failed delegates={DelegatesWired} drain={DrainStarted} reason={Reason}",
+ delegatesWired, drainStarted, ex.Message);
+ }
+ }
+
+ ///
+ /// Builds and sets the online/offline delegates on the platform LocationTrackingService.
+ ///
+ ///
+ /// Online submit delegate return semantics:
+ ///
+ /// - Returns serverId (int): Server accepted the location.
+ /// - Returns null: Server explicitly skipped (threshold not met) — don't queue.
+ /// - Throws exception: Network or API failure — triggers offline queue fallback.
+ ///
+ /// Offline queue delegate: Queues for background sync and adds a
+ /// pending entry to the local timeline (updated when queue drains).
+ ///
+ private static void WireLocationDelegates(
+ IApiClient apiClient,
+ ILocationQueueRepository locationQueue,
+ LocalTimelineStorageService? timelineStorageService)
+ {
+ // Online submit delegate: Submit directly to server via log-location endpoint
+ // Return semantics:
+ // - Returns serverId: Server accepted the location
+ // - Returns null: Server explicitly skipped (threshold not met) - don't queue
+ // - Throws exception: Network or API failure - triggers offline queue fallback
+ Func> onlineSubmit = async location =>
+ {
+ // Early connectivity check - avoids timeout delays when completely offline.
+ // Only block on NetworkAccess.None (no network interface at all).
+ // Allow Local/ConstrainedInternet to attempt the call for LAN-only server deployments.
+ // The IsTransient check below handles actual network failures.
+ if (Connectivity.Current.NetworkAccess == NetworkAccess.None)
+ throw new HttpRequestException("No network connectivity");
+
+ // Convert to API request model with metadata
+ var request = new LocationLogRequest
+ {
+ Latitude = location.Latitude,
+ Longitude = location.Longitude,
+ Accuracy = location.Accuracy,
+ Altitude = location.Altitude,
+ Speed = location.Speed,
+ Timestamp = location.Timestamp,
+ Provider = location.Provider,
+ Bearing = location.Bearing,
+ // Metadata fields - captured at submission time
+ Source = "mobile-log",
+ IsUserInvoked = false,
+ AppVersion = DeviceMetadataHelper.GetAppVersion(),
+ AppBuild = DeviceMetadataHelper.GetAppBuild(),
+ DeviceModel = DeviceMetadataHelper.GetDeviceModel(),
+ OsVersion = DeviceMetadataHelper.GetOsVersion(),
+ BatteryLevel = DeviceMetadataHelper.GetBatteryLevel(),
+ IsCharging = DeviceMetadataHelper.GetIsCharging()
+ };
+
+ // Call the log-location endpoint (server is authoritative)
+ var result = await apiClient.LogLocationAsync(request, idempotencyKey: null);
+
+ // Case 1: Server accepted or skipped - don't queue
+ // Note: log-location API may return just { "success": true } without locationId
+ if (result.Success)
+ {
+ // Only update local timeline if server returned a locationId (not skipped)
+ if (!result.Skipped && result.LocationId.HasValue && timelineStorageService != null)
+ {
+ await timelineStorageService.AddAcceptedLocationAsync(location, result.LocationId.Value);
+ }
+ // Return locationId if available, null otherwise (either skipped or accepted without ID)
+ return result.LocationId;
+ }
+
+ // Case 2: Transient failure - throw to trigger offline queue
+ // Check both IsTransient flag AND status code, since ApiClient may not set
+ // IsTransient for HTTP status failures (they come through with IsTransient=false).
+ // Transient codes: 408 (Request Timeout), 429 (Too Many Requests), 5xx (Server Error).
+ // QueueDrainService handles these appropriately with retry logic.
+ var isTransientStatusCode = result.StatusCode.HasValue &&
+ (result.StatusCode == 408 || result.StatusCode == 429 || result.StatusCode >= 500);
+
+ if (result.IsTransient || isTransientStatusCode)
+ throw new HttpRequestException($"Transient failure: {result.Message}");
+
+ // Case 3: Permanent API failure (4xx client errors) - return null, don't queue.
+ // These won't succeed on retry (bad request, unauthorized, etc.) and queueing them
+ // creates pending timeline entries that never clear since QueueDrainService's 4xx
+ // handling doesn't emit LocationSkipped events.
+ return null;
+ };
+
+ // Offline queue delegate: Queue for background sync
+ Func> offlineQueue = async location =>
+ {
+ // Queue with isUserInvoked=false (background location)
+ var queuedId = await locationQueue.QueueLocationAsync(location, isUserInvoked: false);
+
+ // Add pending entry to local timeline (will be updated when queue drains)
+ if (timelineStorageService != null)
+ {
+ await timelineStorageService.AddPendingLocationAsync(location, queuedId);
+ }
+
+ return queuedId;
+ };
+
+ // Wire to platform services
+#if ANDROID
+ Platforms.Android.Services.LocationTrackingService.OnlineSubmitDelegate = onlineSubmit;
+ Platforms.Android.Services.LocationTrackingService.OfflineQueueDelegate = offlineQueue;
+#elif IOS
+ Platforms.iOS.Services.LocationTrackingService.OnlineSubmitDelegate = onlineSubmit;
+ Platforms.iOS.Services.LocationTrackingService.OfflineQueueDelegate = offlineQueue;
+#endif
+ }
+}
diff --git a/src/WayfarerMobile/Services/QueueDrainService.cs b/src/WayfarerMobile/Services/QueueDrainService.cs
index bbcc7d0c..ab9200e6 100644
--- a/src/WayfarerMobile/Services/QueueDrainService.cs
+++ b/src/WayfarerMobile/Services/QueueDrainService.cs
@@ -142,6 +142,7 @@ public sealed class QueueDrainService : IDisposable
private volatile bool _isOnline;
private volatile bool _isDisposed;
private volatile bool _isStarted;
+ private int _startGuard; // For thread-safe StartAsync via Interlocked
private int _disposeGuard; // For thread-safe Dispose via Interlocked
private int _drainLoopRunning; // For thread-safe drain loop guard via Interlocked
@@ -189,7 +190,8 @@ public async Task StartAsync()
return;
}
- if (_isStarted)
+ // Atomic guard: only one caller can enter startup
+ if (Interlocked.CompareExchange(ref _startGuard, 1, 0) != 0)
{
_logger.LogDebug("QueueDrainService already started");
return;
@@ -230,6 +232,8 @@ public async Task StartAsync()
}
catch (Exception ex)
{
+ // Reset guard to allow retry on next bootstrap attempt
+ Interlocked.Exchange(ref _startGuard, 0);
_logger.LogError(ex, "Failed to start QueueDrainService");
}
}
diff --git a/src/WayfarerMobile/Services/TimelineSyncService.cs b/src/WayfarerMobile/Services/TimelineSyncService.cs
index c4eb6cf1..3c8c66ce 100644
--- a/src/WayfarerMobile/Services/TimelineSyncService.cs
+++ b/src/WayfarerMobile/Services/TimelineSyncService.cs
@@ -110,6 +110,7 @@ public sealed class TimelineSyncService : ITimelineSyncService
private volatile bool _isDisposed;
private volatile bool _isStarted;
private bool _initialized;
+ private int _startGuard; // For thread-safe StartAsync via Interlocked
private int _disposeGuard; // For thread-safe Dispose via Interlocked
private int _drainLoopRunning; // For thread-safe drain loop guard via Interlocked
@@ -190,7 +191,8 @@ public async Task StartAsync()
return;
}
- if (_isStarted)
+ // Atomic guard: only one caller can enter startup
+ if (Interlocked.CompareExchange(ref _startGuard, 1, 0) != 0)
{
_logger.LogDebug("TimelineSyncService already started");
return;
@@ -224,6 +226,8 @@ public async Task StartAsync()
}
catch (Exception ex)
{
+ // Reset guard to allow retry on next bootstrap attempt
+ Interlocked.Exchange(ref _startGuard, 0);
_logger.LogError(ex, "Failed to start TimelineSyncService");
}
}
diff --git a/src/WayfarerMobile/WayfarerMobile.csproj b/src/WayfarerMobile/WayfarerMobile.csproj
index 5f230810..ec236dce 100644
--- a/src/WayfarerMobile/WayfarerMobile.csproj
+++ b/src/WayfarerMobile/WayfarerMobile.csproj
@@ -91,6 +91,11 @@
+
+
+
+
+
diff --git a/tests/WayfarerMobile.Tests/Infrastructure/Mocks/MockSettingsService.cs b/tests/WayfarerMobile.Tests/Infrastructure/Mocks/MockSettingsService.cs
index 2ae2c3fa..364023c3 100644
--- a/tests/WayfarerMobile.Tests/Infrastructure/Mocks/MockSettingsService.cs
+++ b/tests/WayfarerMobile.Tests/Infrastructure/Mocks/MockSettingsService.cs
@@ -143,6 +143,9 @@ public void ClearSyncReference()
#endregion
+ ///
+ public Task PreloadSecureSettingsAsync() => Task.CompletedTask;
+
#region Clear Methods
public void Clear()