diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 0b606ec..2f6dc44 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -16,6 +16,8 @@ jobs: os: [ubuntu-latest, windows-latest, macos-latest] runs-on: ${{ matrix.os }} + # Fail hung test runs quickly instead of waiting for GitHub's 6-hour default + timeout-minutes: 30 steps: - uses: actions/checkout@v7 diff --git a/CHANGELOG.md b/CHANGELOG.md index 330f177..2832927 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- `SynchronizeAsync`, `SyncFolderAsync`, and `SyncFilesAsync` called without `SyncOptions` ignored the documented option defaults, so `PreserveTimestamps` and `PreservePermissions` were effectively off. The engine now falls back to a default `SyncOptions` instance. Besides losing timestamps, this could make a later sync treat an unchanged file as modified and raise a spurious conflict. + ## [1.0.3] - 2026-07-17 ### Security diff --git a/src/SharpSync/Sync/SyncEngine.cs b/src/SharpSync/Sync/SyncEngine.cs index 33d1e8b..0d7fbcd 100644 --- a/src/SharpSync/Sync/SyncEngine.cs +++ b/src/SharpSync/Sync/SyncEngine.cs @@ -194,7 +194,7 @@ public async Task SynchronizeAsync(SyncOptions? options = null, Canc } _currentSyncCts = linkedCts; _currentMaxBytesPerSecond = options?.MaxBytesPerSecond; - _currentOptions = options; + _currentOptions = options ?? new SyncOptions(); var syncToken = linkedCts.Token; var result = new SyncResult(); var sw = Stopwatch.StartNew(); @@ -1722,7 +1722,7 @@ public async Task SyncFolderAsync(string folderPath, SyncOptions? op } _currentSyncCts = linkedCts; _currentMaxBytesPerSecond = options?.MaxBytesPerSecond; - _currentOptions = options; + _currentOptions = options ?? new SyncOptions(); var syncToken = linkedCts.Token; var result = new SyncResult(); var sw = Stopwatch.StartNew(); @@ -1807,7 +1807,7 @@ public async Task SyncFilesAsync(IEnumerable filePaths, Sync } _currentSyncCts = linkedCts; _currentMaxBytesPerSecond = options?.MaxBytesPerSecond; - _currentOptions = options; + _currentOptions = options ?? new SyncOptions(); var syncToken = linkedCts.Token; var result = new SyncResult(); var sw = Stopwatch.StartNew(); diff --git a/tests/SharpSync.Tests/Sync/SyncEngineOptionsTests.cs b/tests/SharpSync.Tests/Sync/SyncEngineOptionsTests.cs index 5b038c7..277cd00 100644 --- a/tests/SharpSync.Tests/Sync/SyncEngineOptionsTests.cs +++ b/tests/SharpSync.Tests/Sync/SyncEngineOptionsTests.cs @@ -527,6 +527,22 @@ public async Task SynchronizeAsync_PreserveTimestampsFalse_DoesNotPreserve() { Assert.True((DateTime.UtcNow - remoteTimestamp).TotalSeconds < 30); } + [Fact] + public async Task SynchronizeAsync_NoOptions_PreservesTimestampsByDefault() { + // Arrange + var specificTime = new DateTime(2024, 3, 10, 8, 0, 0, DateTimeKind.Utc); + await File.WriteAllTextAsync(Path.Combine(_localDir, "default_ts.txt"), "default timestamp"); + File.SetLastWriteTimeUtc(Path.Combine(_localDir, "default_ts.txt"), specificTime); + + // Act - no options passed, so SyncOptions defaults (PreserveTimestamps = true) must apply + var result = await _syncEngine.SynchronizeAsync(); + + Assert.True(result.Success); + + var remoteTimestamp = File.GetLastWriteTimeUtc(Path.Combine(_remoteDir, "default_ts.txt")); + Assert.Equal(specificTime, remoteTimestamp, TimeSpan.FromSeconds(2)); + } + #endregion #region PreservePermissions with Mocks diff --git a/tests/SharpSync.Tests/Sync/SyncEngineTests.cs b/tests/SharpSync.Tests/Sync/SyncEngineTests.cs index 628cfbc..cea07c1 100644 --- a/tests/SharpSync.Tests/Sync/SyncEngineTests.cs +++ b/tests/SharpSync.Tests/Sync/SyncEngineTests.cs @@ -1099,32 +1099,39 @@ public async Task PauseAsync_CalledMultipleTimes_IsIdempotent() { await File.WriteAllTextAsync(filePath, "content"); } - var pauseSignal = new TaskCompletionSource(); + var pauseRequested = 0; + var pauseCalls = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _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(); - }); + // Pause once, from the first non-scanning event only. All three calls happen here, + // before the test resumes, so no late pause can leave the engine paused forever. + if (args.Operation != SyncOperation.Scanning && Interlocked.Exchange(ref pauseRequested, 1) == 0) { + pauseCalls.TrySetResult([_syncEngine.PauseAsync(), _syncEngine.PauseAsync(), _syncEngine.PauseAsync()]); } }; // Act var syncTask = Task.Run(() => _syncEngine.SynchronizeAsync()); - await Task.WhenAny(pauseSignal.Task, Task.Delay(TimeSpan.FromSeconds(2))); + if (await Task.WhenAny(pauseCalls.Task, syncTask) == pauseCalls.Task) { + var calls = await pauseCalls.Task; + + // Repeated calls while already paused are no-ops and complete immediately + Assert.True(calls[1].IsCompleted); + Assert.True(calls[2].IsCompleted); + + // Wait for a pause point, or for sync to finish if the pause came after the last one + await Task.WhenAny(calls[0], syncTask); + } // Resume to complete await _syncEngine.ResumeAsync(); - var result = await syncTask; + var result = await syncTask.WaitAsync(TimeSpan.FromSeconds(30)); // Assert - Should complete without errors Assert.True(result.Success); + Assert.Equal(SyncEngineState.Idle, _syncEngine.State); } [Fact]