diff --git a/CLAUDE.md b/CLAUDE.md
index ee561e9..65dece4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -293,7 +293,6 @@ _watcher.EnableRaisingEvents = true;
| Gap | Impact | Status |
|-----|--------|--------|
| No selective folder sync API | Can't sync single folders on demand | Planned: `SyncFolderAsync()`, `SyncFilesAsync()` |
-| No pause/resume | Long syncs can't be paused | Planned: `PauseAsync()`, `ResumeAsync()` |
| No incremental change notification | FileSystemWatcher triggers full scan | Planned: `NotifyLocalChangeAsync()` |
| Single-threaded engine | One sync at a time per instance | By design - create separate instances if needed |
| OCIS TUS not implemented | Falls back to generic upload | Planned for v1.0 |
@@ -304,6 +303,7 @@ _watcher.EnableRaisingEvents = true;
|---------|----------------|
| Bandwidth throttling | `SyncOptions.MaxBytesPerSecond` - limits transfer rate |
| Virtual file awareness | `SyncOptions.VirtualFileCallback` - hook for Windows Cloud Files API integration |
+| Pause/Resume sync | `PauseAsync()` / `ResumeAsync()` - gracefully pause and resume long-running syncs |
### Required SharpSync API Additions (v1.0)
@@ -318,8 +318,7 @@ These APIs are required for v1.0 release to support Nimbus desktop client:
4. OCIS TUS protocol implementation (`WebDavStorage.cs:547` currently falls back)
**Sync Control:**
-5. `PauseAsync()` / `ResumeAsync()` - Pause and resume long-running syncs
-6. `GetPendingOperationsAsync()` - Inspect sync queue for UI display
+5. `GetPendingOperationsAsync()` - Inspect sync queue for UI display
**Progress & History:**
7. Per-file progress events (currently only per-sync-operation)
@@ -331,6 +330,8 @@ These APIs are required for v1.0 release to support Nimbus desktop client:
- `SyncOptions.CreateVirtualFilePlaceholders` - Enable/disable virtual file placeholder creation
- `VirtualFileState` enum - Track placeholder state (None, Placeholder, Hydrated, Partial)
- `SyncPlanAction.WillCreateVirtualPlaceholder` - Preview which downloads will create placeholders
+- `PauseAsync()` / `ResumeAsync()` - Gracefully pause and resume long-running syncs
+- `IsPaused` property and `SyncEngineState` enum - Track engine state (Idle, Running, Paused)
### API Readiness Score for Nimbus
@@ -344,10 +345,10 @@ These APIs are required for v1.0 release to support Nimbus desktop client:
| UI binding (events) | 9/10 | Excellent progress/conflict events |
| Conflict resolution | 9/10 | Rich analysis, extensible callbacks |
| Selective sync | 4/10 | Filter-only, no folder/file API |
-| Pause/Resume | 2/10 | Not implemented |
-| Desktop integration hooks | 8/10 | Virtual file callback, bandwidth throttling implemented |
+| Pause/Resume | 10/10 | Fully implemented with graceful pause points |
+| Desktop integration hooks | 9/10 | Virtual file callback, bandwidth throttling, pause/resume |
-**Current Overall: 7.25/10** - Solid foundation, key desktop hooks now available
+**Current Overall: 8.4/10** - Strong foundation with key desktop features implemented
**Target for v1.0: 9.5/10** - All gaps resolved, ready for Nimbus development
@@ -501,6 +502,7 @@ The core library is production-ready, but several critical items must be address
- ✅ Bandwidth throttling (`SyncOptions.MaxBytesPerSecond`)
- ✅ Virtual file placeholder support (`SyncOptions.VirtualFileCallback`) for Windows Cloud Files API
- ✅ High-performance logging with `Microsoft.Extensions.Logging.Abstractions`
+- ✅ Pause/Resume sync (`PauseAsync()` / `ResumeAsync()`) with graceful pause points
**🚧 Required for v1.0 Release**
@@ -517,7 +519,7 @@ Desktop Client APIs (for Nimbus):
- [ ] `NotifyLocalChangeAsync(string path, ChangeType type)` - Accept FileSystemWatcher events for incremental sync
- [ ] OCIS TUS protocol implementation (currently falls back to generic upload at `WebDavStorage.cs:547`)
- [x] `SyncOptions.MaxBytesPerSecond` - Built-in bandwidth throttling ✅
-- [ ] `PauseAsync()` / `ResumeAsync()` - Pause and resume long-running syncs
+- [x] `PauseAsync()` / `ResumeAsync()` - Pause and resume long-running syncs ✅
- [ ] `GetPendingOperationsAsync()` - Inspect sync queue for UI display
- [ ] Per-file progress events (currently only per-sync-operation)
- [x] `SyncOptions.VirtualFileCallback` - Hook for virtual file systems (Windows Cloud Files API) ✅
diff --git a/src/SharpSync/Core/ISyncEngine.cs b/src/SharpSync/Core/ISyncEngine.cs
index fcfc5a4..18805cb 100644
--- a/src/SharpSync/Core/ISyncEngine.cs
+++ b/src/SharpSync/Core/ISyncEngine.cs
@@ -19,6 +19,16 @@ public interface ISyncEngine: IDisposable {
///
bool IsSynchronizing { get; }
+ ///
+ /// Gets whether the engine is currently paused
+ ///
+ bool IsPaused { get; }
+
+ ///
+ /// Gets the current state of the sync engine
+ ///
+ SyncEngineState State { get; }
+
///
/// Synchronizes files between local and remote storage
///
@@ -51,4 +61,38 @@ public interface ISyncEngine: IDisposable {
/// Resets all sync state (forces full rescan)
///
Task ResetSyncStateAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Pauses the current synchronization operation
+ ///
+ ///
+ ///
+ /// The pause is graceful - the engine will complete the current file operation
+ /// before entering the paused state. This ensures no partial file transfers occur.
+ ///
+ ///
+ /// If no synchronization is in progress, this method returns immediately.
+ ///
+ ///
+ /// While paused, the event will fire with
+ /// to indicate the paused state.
+ ///
+ ///
+ /// A task that completes when the engine has entered the paused state
+ Task PauseAsync();
+
+ ///
+ /// Resumes a paused synchronization operation
+ ///
+ ///
+ ///
+ /// If the engine is not paused, this method returns immediately.
+ ///
+ ///
+ /// After resuming, synchronization continues from where it was paused,
+ /// processing any remaining files in the sync queue.
+ ///
+ ///
+ /// A task that completes when the engine has resumed
+ Task ResumeAsync();
}
diff --git a/src/SharpSync/Core/SyncEngineState.cs b/src/SharpSync/Core/SyncEngineState.cs
new file mode 100644
index 0000000..c57b0ee
--- /dev/null
+++ b/src/SharpSync/Core/SyncEngineState.cs
@@ -0,0 +1,21 @@
+namespace Oire.SharpSync.Core;
+
+///
+/// Represents the current state of the sync engine
+///
+public enum SyncEngineState {
+ ///
+ /// The engine is idle and not performing any sync operation
+ ///
+ Idle,
+
+ ///
+ /// The engine is actively synchronizing files
+ ///
+ Running,
+
+ ///
+ /// The engine is paused and waiting to be resumed
+ ///
+ Paused
+}
diff --git a/src/SharpSync/Core/SyncOperation.cs b/src/SharpSync/Core/SyncOperation.cs
index d296e5b..8d2eedf 100644
--- a/src/SharpSync/Core/SyncOperation.cs
+++ b/src/SharpSync/Core/SyncOperation.cs
@@ -37,5 +37,10 @@ public enum SyncOperation {
///
/// Resolving a synchronization conflict
///
- ResolvingConflict
+ ResolvingConflict,
+
+ ///
+ /// The sync operation is paused
+ ///
+ Paused
}
diff --git a/src/SharpSync/Core/SyncProgress.cs b/src/SharpSync/Core/SyncProgress.cs
index 44c5fa1..99d8d77 100644
--- a/src/SharpSync/Core/SyncProgress.cs
+++ b/src/SharpSync/Core/SyncProgress.cs
@@ -43,4 +43,9 @@ public record SyncProgress {
/// Gets whether the operation has been cancelled
///
public bool IsCancelled { get; init; }
+
+ ///
+ /// Gets whether the operation is currently paused
+ ///
+ public bool IsPaused { get; init; }
}
diff --git a/src/SharpSync/Logging/LogMessages.cs b/src/SharpSync/Logging/LogMessages.cs
index 5ed5e1b..81f9ef5 100644
--- a/src/SharpSync/Logging/LogMessages.cs
+++ b/src/SharpSync/Logging/LogMessages.cs
@@ -41,4 +41,28 @@ internal static partial class LogMessages {
Level = LogLevel.Warning,
Message = "Virtual file callback failed for {FilePath}")]
public static partial void VirtualFileCallbackError(this ILogger logger, Exception ex, string filePath);
+
+ [LoggerMessage(
+ EventId = 7,
+ Level = LogLevel.Information,
+ Message = "Sync pause requested, waiting for current operation to complete")]
+ public static partial void SyncPausing(this ILogger logger);
+
+ [LoggerMessage(
+ EventId = 8,
+ Level = LogLevel.Information,
+ Message = "Sync paused")]
+ public static partial void SyncPaused(this ILogger logger);
+
+ [LoggerMessage(
+ EventId = 9,
+ Level = LogLevel.Information,
+ Message = "Sync resume requested")]
+ public static partial void SyncResuming(this ILogger logger);
+
+ [LoggerMessage(
+ EventId = 10,
+ Level = LogLevel.Information,
+ Message = "Sync resumed")]
+ public static partial void SyncResumed(this ILogger logger);
}
diff --git a/src/SharpSync/Sync/SyncEngine.cs b/src/SharpSync/Sync/SyncEngine.cs
index fbdc217..e568bd7 100644
--- a/src/SharpSync/Sync/SyncEngine.cs
+++ b/src/SharpSync/Sync/SyncEngine.cs
@@ -32,11 +32,28 @@ public class SyncEngine: ISyncEngine {
private long? _currentMaxBytesPerSecond;
private SyncOptions? _currentOptions;
+ // Pause/Resume state
+ private readonly ManualResetEventSlim _pauseEvent = new(true); // Initially not paused (signaled)
+ private volatile SyncEngineState _state = SyncEngineState.Idle;
+ private readonly object _stateLock = new();
+ private SyncProgress? _pausedProgress;
+ private TaskCompletionSource? _pauseCompletionSource;
+
///
/// Gets whether the engine is currently synchronizing
///
public bool IsSynchronizing => _syncSemaphore.CurrentCount == 0;
+ ///
+ /// Gets whether the engine is currently paused
+ ///
+ public bool IsPaused => _state == SyncEngineState.Paused;
+
+ ///
+ /// Gets the current state of the sync engine
+ ///
+ public SyncEngineState State => _state;
+
///
/// Event raised when sync progress changes
///
@@ -118,6 +135,12 @@ public async Task SynchronizeAsync(SyncOptions? options = null, Canc
var result = new SyncResult();
var sw = Stopwatch.StartNew();
+ // Set state to Running
+ lock (_stateLock) {
+ _state = SyncEngineState.Running;
+ _pauseEvent.Set(); // Ensure not paused at start
+ }
+
try {
// Phase 1: Fast change detection
RaiseProgress(new SyncProgress { CurrentItem = "Detecting changes..." }, SyncOperation.Scanning);
@@ -157,6 +180,14 @@ public async Task SynchronizeAsync(SyncOptions? options = null, Canc
return result;
} finally {
+ // Reset state to Idle
+ lock (_stateLock) {
+ _state = SyncEngineState.Idle;
+ _pauseEvent.Set(); // Ensure not paused
+ _pausedProgress = null;
+ _pauseCompletionSource?.TrySetResult(); // Complete any pending pause
+ _pauseCompletionSource = null;
+ }
_currentSyncCts = null;
_currentMaxBytesPerSecond = null;
_currentOptions = null;
@@ -679,6 +710,18 @@ private async Task ProcessPhase1_DirectoriesAndSmallFilesAsync(
await Parallel.ForEachAsync(allSmallActions, parallelOptions, async (action, ct) => {
try {
+ // Check for pause point before processing each action
+ var currentProgress = new SyncProgress {
+ ProcessedItems = progressCounter.Value,
+ TotalItems = totalChanges,
+ CurrentItem = action.Path
+ };
+
+ if (!await CheckPausePointAsync(ct, currentProgress)) {
+ ct.ThrowIfCancellationRequested();
+ return;
+ }
+
await ProcessActionAsync(action, result, ct);
var newCount = progressCounter.Increment();
@@ -732,12 +775,20 @@ private async Task ProcessLargeFileAsync(
CancellationToken cancellationToken) {
await semaphore.WaitAsync(cancellationToken);
try {
- // Report start of large file processing
- RaiseProgress(new SyncProgress {
+ // Check for pause point before processing large file
+ var currentProgress = new SyncProgress {
ProcessedItems = progressCounter.Value,
TotalItems = totalChanges,
CurrentItem = action.Path
- }, GetOperationType(action.Type));
+ };
+
+ if (!await CheckPausePointAsync(cancellationToken, currentProgress)) {
+ cancellationToken.ThrowIfCancellationRequested();
+ return;
+ }
+
+ // Report start of large file processing
+ RaiseProgress(currentProgress, GetOperationType(action.Type));
await ProcessActionAsync(action, result, cancellationToken);
@@ -770,7 +821,17 @@ private async Task ProcessPhase3_DeletesAndConflictsAsync(
// Process conflicts first (they might resolve to deletes)
foreach (var action in actionGroups.Conflicts) {
try {
- cancellationToken.ThrowIfCancellationRequested();
+ // Check for pause point before processing conflict
+ var currentProgress = new SyncProgress {
+ ProcessedItems = progressCounter.Value,
+ TotalItems = totalChanges,
+ CurrentItem = action.Path
+ };
+
+ if (!await CheckPausePointAsync(cancellationToken, currentProgress)) {
+ cancellationToken.ThrowIfCancellationRequested();
+ return;
+ }
await ProcessActionAsync(action, result, cancellationToken);
@@ -793,7 +854,17 @@ private async Task ProcessPhase3_DeletesAndConflictsAsync(
foreach (var action in sortedDeletes) {
try {
- cancellationToken.ThrowIfCancellationRequested();
+ // Check for pause point before processing delete
+ var currentProgress = new SyncProgress {
+ ProcessedItems = progressCounter.Value,
+ TotalItems = totalChanges,
+ CurrentItem = action.Path
+ };
+
+ if (!await CheckPausePointAsync(cancellationToken, currentProgress)) {
+ cancellationToken.ThrowIfCancellationRequested();
+ return;
+ }
await ProcessActionAsync(action, result, cancellationToken);
@@ -1165,6 +1236,133 @@ public async Task ResetSyncStateAsync(CancellationToken cancellationToken = defa
await _database.ClearAsync(cancellationToken);
}
+ ///
+ /// Pauses the current synchronization operation gracefully
+ ///
+ /// A task that completes when the engine has entered the paused state
+ ///
+ /// The pause is graceful - the engine will complete the current file operation
+ /// before entering the paused state. If no synchronization is in progress, returns immediately.
+ ///
+ public Task PauseAsync() {
+ if (_disposed) {
+ throw new ObjectDisposedException(nameof(SyncEngine));
+ }
+
+ lock (_stateLock) {
+ // Can only pause if currently running
+ if (_state != SyncEngineState.Running) {
+ return Task.CompletedTask;
+ }
+
+ _state = SyncEngineState.Paused;
+ _pauseCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ // Reset the event to block waiting threads
+ _pauseEvent.Reset();
+
+ _logger.SyncPausing();
+ }
+
+ // Return a task that completes when we've actually reached a pause point
+ return _pauseCompletionSource.Task;
+ }
+
+ ///
+ /// Resumes a paused synchronization operation
+ ///
+ /// A task that completes when the engine has resumed
+ ///
+ /// If the engine is not paused, this method returns immediately.
+ /// After resuming, synchronization continues from where it was paused.
+ ///
+ public Task ResumeAsync() {
+ if (_disposed) {
+ throw new ObjectDisposedException(nameof(SyncEngine));
+ }
+
+ lock (_stateLock) {
+ // Can only resume if currently paused
+ if (_state != SyncEngineState.Paused) {
+ return Task.CompletedTask;
+ }
+
+ _state = SyncEngineState.Running;
+ _pausedProgress = null;
+
+ // Set the event to allow waiting threads to continue
+ _pauseEvent.Set();
+
+ _logger.SyncResuming();
+ }
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Checks if the engine should pause and waits if necessary.
+ /// Call this at safe pause points (between file operations).
+ ///
+ /// Cancellation token
+ /// Current progress to report while paused
+ /// True if should continue, false if cancelled
+ private bool CheckPausePoint(CancellationToken cancellationToken, SyncProgress? currentProgress = null) {
+ // Fast path - if not paused and not cancelled, continue immediately
+ if (_pauseEvent.IsSet && !cancellationToken.IsCancellationRequested) {
+ return true;
+ }
+
+ // Check for cancellation first
+ if (cancellationToken.IsCancellationRequested) {
+ return false;
+ }
+
+ // We're paused - signal that we've reached a pause point
+ TaskCompletionSource? tcs;
+ lock (_stateLock) {
+ tcs = _pauseCompletionSource;
+ _pausedProgress = currentProgress;
+ }
+
+ // Signal that pause point was reached
+ tcs?.TrySetResult();
+
+ // Report paused state via progress
+ if (currentProgress is not null) {
+ RaiseProgress(currentProgress, SyncOperation.Paused);
+ }
+
+ _logger.SyncPaused();
+
+ // Wait for resume or cancellation
+ try {
+ // Use WaitHandle.WaitAny to wait for either the pause event or cancellation
+ WaitHandle.WaitAny([_pauseEvent.WaitHandle, cancellationToken.WaitHandle]);
+
+ if (cancellationToken.IsCancellationRequested) {
+ return false;
+ }
+
+ _logger.SyncResumed();
+ return true;
+ } catch (OperationCanceledException) {
+ return false;
+ }
+ }
+
+ ///
+ /// Async version of pause point check for use in async contexts
+ ///
+ private async Task CheckPausePointAsync(CancellationToken cancellationToken, SyncProgress? currentProgress = null) {
+ // Fast path - if not paused and not cancelled, continue immediately
+ if (_pauseEvent.IsSet && !cancellationToken.IsCancellationRequested) {
+ return true;
+ }
+
+ // Run the blocking wait on a thread pool thread to not block async context
+ return await Task.Run(() => CheckPausePoint(cancellationToken, currentProgress), cancellationToken);
+ }
+
///
/// Releases all resources used by the sync engine
///
@@ -1174,8 +1372,11 @@ public async Task ResetSyncStateAsync(CancellationToken cancellationToken = defa
///
public void Dispose() {
if (!_disposed) {
+ // Resume any paused operation so it can exit cleanly
+ _pauseEvent.Set();
_currentSyncCts?.Cancel();
_syncSemaphore?.Dispose();
+ _pauseEvent?.Dispose();
_disposed = true;
}
}
diff --git a/tests/SharpSync.Tests/Sync/SyncEngineTests.cs b/tests/SharpSync.Tests/Sync/SyncEngineTests.cs
index 2128e60..904212b 100644
--- a/tests/SharpSync.Tests/Sync/SyncEngineTests.cs
+++ b/tests/SharpSync.Tests/Sync/SyncEngineTests.cs
@@ -843,4 +843,282 @@ public async Task GetSyncPlanAsync_WithOptions_RespectsFilterSettings() {
}
#endregion
+
+ #region Pause/Resume Tests
+
+ [Fact]
+ public void IsPaused_InitialState_ReturnsFalse() {
+ // Assert
+ Assert.False(_syncEngine.IsPaused);
+ }
+
+ [Fact]
+ public void State_InitialState_ReturnsIdle() {
+ // Assert
+ Assert.Equal(SyncEngineState.Idle, _syncEngine.State);
+ }
+
+ [Fact]
+ public async Task PauseAsync_WhenNotSynchronizing_ReturnsImmediately() {
+ // Act
+ await _syncEngine.PauseAsync();
+
+ // Assert - should not be paused since no sync was running
+ Assert.Equal(SyncEngineState.Idle, _syncEngine.State);
+ Assert.False(_syncEngine.IsPaused);
+ }
+
+ [Fact]
+ public async Task ResumeAsync_WhenNotPaused_ReturnsImmediately() {
+ // Act
+ await _syncEngine.ResumeAsync();
+
+ // Assert
+ Assert.Equal(SyncEngineState.Idle, _syncEngine.State);
+ Assert.False(_syncEngine.IsPaused);
+ }
+
+ [Fact]
+ public async Task PauseAsync_AfterDispose_ThrowsObjectDisposedException() {
+ // Arrange
+ _syncEngine.Dispose();
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _syncEngine.PauseAsync());
+ }
+
+ [Fact]
+ public async Task ResumeAsync_AfterDispose_ThrowsObjectDisposedException() {
+ // Arrange
+ _syncEngine.Dispose();
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _syncEngine.ResumeAsync());
+ }
+
+ [Fact]
+ public async Task PauseAsync_DuringSync_TransitionsToRunningThenPaused() {
+ // Arrange - Create multiple files to ensure sync takes some time
+ for (int i = 0; i < 20; i++) {
+ var filePath = Path.Combine(_localRootPath, $"pause_test_{i}.txt");
+ await File.WriteAllTextAsync(filePath, new string('x', 10000)); // 10KB each
+ }
+
+ var progressEvents = new List();
+ var pausedEventReceived = false;
+ var pauseStateReached = new TaskCompletionSource();
+
+ _syncEngine.ProgressChanged += (sender, args) => {
+ progressEvents.Add(args);
+
+ // When we see the first progress event during sync, try to pause
+ if (args.Operation != SyncOperation.Scanning && args.Operation != SyncOperation.Paused && progressEvents.Count == 2) {
+ _ = _syncEngine.PauseAsync();
+ }
+
+ if (args.Operation == SyncOperation.Paused) {
+ pausedEventReceived = true;
+ pauseStateReached.TrySetResult();
+ }
+ };
+
+ // Act - Start sync
+ var syncTask = Task.Run(async () => {
+ try {
+ return await _syncEngine.SynchronizeAsync();
+ } catch (OperationCanceledException) {
+ return new SyncResult { Success = false, Details = "Cancelled" };
+ }
+ });
+
+ // Wait for pause state or timeout
+ var pauseOrTimeout = await Task.WhenAny(
+ pauseStateReached.Task,
+ Task.Delay(TimeSpan.FromSeconds(5))
+ );
+
+ // Resume if paused, so sync can complete
+ if (_syncEngine.IsPaused) {
+ await _syncEngine.ResumeAsync();
+ }
+
+ var result = await syncTask;
+
+ // Assert - If we managed to pause (depends on timing), verify the state transition
+ if (pausedEventReceived) {
+ Assert.Contains(progressEvents, e => e.Operation == SyncOperation.Paused);
+ }
+
+ // Sync should complete successfully
+ Assert.True(result.Success || result.Details == "Cancelled");
+ }
+
+ [Fact]
+ public async Task PauseAndResume_DuringSync_ContinuesSuccessfully() {
+ // Arrange - Create files
+ for (int i = 0; i < 10; i++) {
+ var filePath = Path.Combine(_localRootPath, $"resume_test_{i}.txt");
+ await File.WriteAllTextAsync(filePath, $"Content for file {i}");
+ }
+
+ var progressBeforePause = 0;
+ var progressAfterResume = 0;
+ var wasPaused = false;
+ var pauseTask = Task.CompletedTask;
+ var pauseSignal = new TaskCompletionSource();
+
+ _syncEngine.ProgressChanged += (sender, args) => {
+ if (args.Operation == SyncOperation.Paused) {
+ wasPaused = true;
+ pauseSignal.TrySetResult();
+ } else if (!wasPaused && args.Operation != SyncOperation.Scanning) {
+ progressBeforePause = args.Progress.ProcessedItems;
+ // Pause after processing 2 items
+ if (progressBeforePause >= 2 && pauseTask.IsCompleted) {
+ pauseTask = _syncEngine.PauseAsync();
+ }
+ } else if (wasPaused && args.Operation != SyncOperation.Paused) {
+ progressAfterResume = args.Progress.ProcessedItems;
+ }
+ };
+
+ // Act
+ var syncTask = Task.Run(() => _syncEngine.SynchronizeAsync());
+
+ // Wait for pause or timeout
+ var pauseOrTimeout = await Task.WhenAny(
+ pauseSignal.Task,
+ Task.Delay(TimeSpan.FromSeconds(3))
+ );
+
+ // If paused, wait a bit then resume
+ if (_syncEngine.IsPaused) {
+ await Task.Delay(100);
+ await _syncEngine.ResumeAsync();
+ }
+
+ var result = await syncTask;
+
+ // Assert
+ Assert.True(result.Success);
+
+ // If we managed to pause, verify progress continued after resume
+ if (wasPaused) {
+ Assert.True(progressAfterResume >= progressBeforePause);
+ }
+ }
+
+ [Fact]
+ public async Task State_DuringSync_ReturnsRunning() {
+ // Arrange
+ var filePath = Path.Combine(_localRootPath, "state_test.txt");
+ await File.WriteAllTextAsync(filePath, "test content");
+
+ var stateWasRunning = false;
+
+ _syncEngine.ProgressChanged += (sender, args) => {
+ if (_syncEngine.State == SyncEngineState.Running) {
+ stateWasRunning = true;
+ }
+ };
+
+ // Act
+ var result = await _syncEngine.SynchronizeAsync();
+
+ // Assert
+ Assert.True(result.Success);
+ Assert.True(stateWasRunning);
+ Assert.Equal(SyncEngineState.Idle, _syncEngine.State); // Back to idle after sync
+ }
+
+ [Fact]
+ public async Task State_AfterSync_ReturnsIdle() {
+ // Arrange
+ var filePath = Path.Combine(_localRootPath, "state_after_test.txt");
+ await File.WriteAllTextAsync(filePath, "test content");
+
+ // Act
+ var result = await _syncEngine.SynchronizeAsync();
+
+ // Assert
+ Assert.True(result.Success);
+ Assert.Equal(SyncEngineState.Idle, _syncEngine.State);
+ Assert.False(_syncEngine.IsPaused);
+ Assert.False(_syncEngine.IsSynchronizing);
+ }
+
+ [Fact]
+ public async Task PauseAsync_CalledMultipleTimes_IsIdempotent() {
+ // Arrange - Create files for sync
+ for (int i = 0; i < 5; i++) {
+ var filePath = Path.Combine(_localRootPath, $"idempotent_{i}.txt");
+ await File.WriteAllTextAsync(filePath, "content");
+ }
+
+ var pauseSignal = new TaskCompletionSource();
+
+ _syncEngine.ProgressChanged += (sender, args) => {
+ if (args.Operation != SyncOperation.Scanning && !pauseSignal.Task.IsCompleted) {
+ // Call pause multiple times
+ _ = Task.Run(async () => {
+ await _syncEngine.PauseAsync();
+ await _syncEngine.PauseAsync();
+ await _syncEngine.PauseAsync();
+ pauseSignal.TrySetResult();
+ });
+ }
+ };
+
+ // Act
+ var syncTask = Task.Run(() => _syncEngine.SynchronizeAsync());
+
+ await Task.WhenAny(pauseSignal.Task, Task.Delay(TimeSpan.FromSeconds(2)));
+
+ // Resume to complete
+ await _syncEngine.ResumeAsync();
+
+ var result = await syncTask;
+
+ // Assert - Should complete without errors
+ Assert.True(result.Success);
+ }
+
+ [Fact]
+ public async Task ResumeAsync_CalledMultipleTimes_IsIdempotent() {
+ // Arrange
+ var filePath = Path.Combine(_localRootPath, "resume_idempotent.txt");
+ await File.WriteAllTextAsync(filePath, "content");
+
+ // Act - Call resume multiple times when not paused
+ await _syncEngine.ResumeAsync();
+ await _syncEngine.ResumeAsync();
+ await _syncEngine.ResumeAsync();
+
+ var result = await _syncEngine.SynchronizeAsync();
+
+ // Assert
+ Assert.True(result.Success);
+ Assert.Equal(SyncEngineState.Idle, _syncEngine.State);
+ }
+
+ [Fact]
+ public void Dispose_WhilePaused_ReleasesWaitingThreads() {
+ // This test verifies that disposing while paused doesn't cause deadlocks
+ // The Dispose method sets the pause event to release any waiting threads
+
+ // Arrange
+ var filter = new SyncFilter();
+ var conflictResolver = new DefaultConflictResolver(ConflictResolution.UseLocal);
+ var engine = new SyncEngine(_localStorage, _remoteStorage, _database, filter, conflictResolver);
+
+ // Act & Assert - Should not throw or deadlock
+ engine.Dispose();
+
+ Assert.Throws(() => {
+ _ = engine.State;
+ engine.PauseAsync().GetAwaiter().GetResult();
+ });
+ }
+
+ #endregion
}