| name | csharp-cancellation-patterns |
| description | Apply when modifying any async method in the Adapters/ layer (FtpFileClient, SftpFileClient), the IFileClient interface, or AppBridge file-operation calls. Also apply when adding new async methods that interact with FTP/SFTP libraries or when reviewing cancellation correctness. Covers FluentFTP, SSH.NET, and SemaphoreSlim patterns specific to this codebase.
|
| effort | medium |
CancellationToken Patterns for FtpFileClient / SftpFileClient
Three-Layer Architecture
Every file operation flows through three layers. Each layer has its own cancellation rules:
Frontend (JS AbortController)
→ AppBridge (forwards CancellationToken)
→ IFileClient interface (declares CancellationToken parameter)
→ FtpFileClient / SftpFileClient (implements cancellation)
→ FluentFTP / SSH.NET (actual library calls)
Every layer must propagate the token. Breaking the chain at any point defeats cancellation.
Rule 1 — IFileClient Interface
All async interface methods MUST accept CancellationToken as a named parameter with = default:
Task ConnectAsync(ConnectionProfile profile, CancellationToken cancellationToken = default);
Task<List<RemoteItem>> ListDirectoryAsync(string path, CancellationToken cancellationToken = default);
Task UploadAsync(string localPath, string remotePath, IProgress<TransferProgress>? progress = null,
CancellationToken cancellationToken = default);
Never omit the parameter — callers depend on it being there.
Rule 2 — FtpFileClient (FluentFTP)
FluentFTP uses token as the named parameter, NOT cancellationToken.
await ftp.UploadFile(localPath, remotePath,
FtpRemoteExists.Overwrite, createRemoteDir: false, FtpVerify.None,
ftpProgress,
token: cancellationToken);
await ftp.UploadFile(..., cancellationToken: cancellationToken);
Always check the FluentFTP API signature before adding named arguments. The parameter
name is token across all overloads that accept cancellation.
Serialization with SemaphoreSlim
Use _sem.WaitAsync(cancellationToken) to serialize concurrent operations AND gate cancellation:
await _sem.WaitAsync(cancellationToken);
try
{
}
finally { _sem.Release(); }
Do NOT use _sem.Wait() (synchronous, blocks) or _sem.WaitAsync() without the token.
Supported FluentFTP Overloads
These FluentFTP methods accept a CancellationToken token parameter:
| Method | Token support |
|---|
UploadFile | token: — use this named form |
DownloadFile | token: — use this named form |
CreateDirectory | token: — use this named form |
GetObjectInfo | token: — use this named form |
Rename | positional CancellationToken |
DeleteDirectory | positional CancellationToken |
DeleteFile | positional CancellationToken |
GetListing | positional CancellationToken |
For methods that don't accept a token directly, rely on _sem.WaitAsync(token) for
cancellation — the semaphore will abort before the blocking call starts.
Rule 3 — SftpFileClient (SSH.NET)
SSH.NET 2025.x provides two API styles. This codebase uses Task.Run over the sync API as the primary
pattern (simpler callback integration with IProgress<T>). Both approaches are valid:
Option A — Task.Run over sync API (used in this codebase)
SSH.NET 2025.x has synchronous APIs (ListDirectory, UploadFile, DownloadFile) that require
Task.Run to make them cancellable:
return Task.Run(() =>
{
cancellationToken.ThrowIfCancellationRequested();
var items = sftp.ListDirectory(path);
return items.Where(i => i.Name is not ("." or ".."))
.Select(i => new RemoteItem { ... })
.ToList();
}, cancellationToken);
var items = sftp.ListDirectory(path);
Option B — Native async API (SSH.NET 2025.x)
SSH.NET 2025.1.0 introduced native async methods with CancellationToken support:
IAsyncEnumerable<ISftpFile> ListDirectoryAsync(string path, CancellationToken cancellationToken);
Prefer native async for operations that support it. Use Task.Run when you need progress callbacks
or other features only available on the sync API.
ThrowIfCancellationRequested() Checkpoints
Place ThrowIfCancellationRequested() BEFORE blocking I/O operations inside Task.Run:
public async Task UploadAsync(string localPath, string remotePath,
IProgress<TransferProgress>? progress,
CancellationToken cancellationToken = default)
{
var sftp = RequireConnected();
cancellationToken.ThrowIfCancellationRequested();
await Task.Run(() =>
{
cancellationToken.ThrowIfCancellationRequested();
using var input = File.OpenRead(localPath);
sftp.UploadFile(input, remotePath, progress: p => progress?.Report(...));
cancellationToken.ThrowIfCancellationRequested();
}, cancellationToken);
}
Disconnect() Pattern
Disconnect() requires special handling — it must abort in-flight operations AND clean up:
public void Disconnect(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var sftp = _sftp;
_sftp = null;
_profile = null;
if (sftp is null) return;
cancellationToken.ThrowIfCancellationRequested();
try { sftp.Disconnect(); } catch { }
sftp.Dispose();
}
public void Dispose() => Disconnect();
Key points:
- Null the connection field BEFORE calling
sftp.Disconnect() so a racing UploadAsync
sees _sftp == null and throws InvalidOperationException instead of corrupting state
- Two
ThrowIfCancellationRequested() calls: one before state mutation, one before the
blocking disconnect
Dispose() passes default(CancellationToken) — no user cancellation during shutdown
Rule 4 — AppBridge (Frontend Bridge)
AppBridge MUST propagate the CancellationToken from the method parameter to adapter calls:
public async Task ConnectAsync(ConnectionProfile profile,
CancellationToken cancellationToken = default)
{
await client.ConnectAsync(profile, cancellationToken);
}
public async Task ConnectAsync(ConnectionProfile profile,
CancellationToken cancellationToken = default)
{
await client.ConnectAsync(profile, CancellationToken.None);
}
Check every adapter call site. A single hardcoded CancellationToken.None in the call
chain negates cancellation for the entire operation.
Common Mistakes
Mistake 1 — Wrong named parameter for FluentFTP
ftp.UploadFile(path, remote, token: someToken);
ftp.UploadFile(path, remote, token: cancellationToken);
Mistake 2 — Missing Task.Run wrapper for SSH.NET sync API
var items = sftp.ListDirectory(path);
return Task.Run(() => sftp.ListDirectory(path), cancellationToken);
await foreach (var file in sftp.ListDirectoryAsync(path, cancellationToken))
yield return new RemoteItem { ... };
Mistake 3 — Hardcoded CancellationToken.None in AppBridge
await client.UploadAsync(localPath, remotePath, progress, CancellationToken.None);
await client.UploadAsync(localPath, remotePath, progress, cancellationToken);
Mistake 4 — Missing ThrowIfCancellationRequested() before blocking I/O
await Task.Run(() =>
{
sftp.UploadFile(input, remotePath);
}, cancellationToken);
await Task.Run(() =>
{
cancellationToken.ThrowIfCancellationRequested();
sftp.UploadFile(input, remotePath);
cancellationToken.ThrowIfCancellationRequested();
}, cancellationToken);
Mistake 5 — Disconnect called without token during cleanup
client.Disconnect();
client.Disconnect(cancellationToken);
Mistake 6 — No cleanup of partial files on cancellation
When a transfer is cancelled mid-flight, the partial file remains on disk:
await Task.Run(() =>
{
sftp.UploadFile(localStream, remotePath);
}, cancellationToken);
var tempPath = remotePath + ".tmp";
try
{
await Task.Run(() =>
{
cancellationToken.ThrowIfCancellationRequested();
sftp.UploadFile(localStream, tempPath);
}, cancellationToken);
if (File.Exists(remotePath)) File.Delete(remotePath);
sftp.RenameFile(tempPath, remotePath);
}
catch (OperationCanceledException)
{
try { sftp.DeleteFile(tempPath); } catch { }
throw;
}
For downloads, consider deleting the partial local file on cancellation:
catch (OperationCanceledException)
{
if (File.Exists(localPath)) File.Delete(localPath);
throw;
}
Review Checklist
When reviewing any change to the adapter layer, verify: