Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions src/SharpSync/Sync/SyncEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ public async Task<SyncResult> 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();
Expand Down Expand Up @@ -1722,7 +1722,7 @@ public async Task<SyncResult> 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();
Expand Down Expand Up @@ -1807,7 +1807,7 @@ public async Task<SyncResult> SyncFilesAsync(IEnumerable<string> 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();
Expand Down
16 changes: 16 additions & 0 deletions tests/SharpSync.Tests/Sync/SyncEngineOptionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 18 additions & 11 deletions tests/SharpSync.Tests/Sync/SyncEngineTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Task[]>(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]
Expand Down