Move files to and from a host over SFTP

M2's file transfer, built bottom-up: an SFTP session on the SSH layer, a
transfer queue in a project of its own, and the two-pane browser the design
asked for replacing the screen that said it did not exist. Remote listings
carry names, sizes, modification times and a real drwxr-xr-x — nothing in this
repository could render a POSIX mode before — and the queue moves one file at a
time with progress, throughput and resume.

The design import assumed this would be an SFTP subsystem channel on
ISshConnection, beside the shell on a transport that is already up. SSH.NET
does not offer that: SftpClient derives from BaseClient and owns its own
transport, and there is no supported way to hand it an SshClient's session. So
file transfer opens a second authenticated connection, and it is named for
that rather than dressed up as a channel — OpenSftpAsync is on
ISftpSessionFactory, not on a connection. The difference is visible to a user:
the host records a second login, and a host whose password is typed each time
asks for it again on this screen. It goes through the same host key gate, the
same pin and the same two refusals a shell does, so a fingerprint approved for
a terminal is approved here and one approved here reaches the other machines
with the next sync. docs/design-import-gaps.md is corrected, and marked as the
one row where what shipped differs from what it predicted.

Nothing is written at its final name until it is complete. Every transfer goes
to a .dodossh-part file beside its destination and is renamed into place at the
end, so an interrupted transfer can never be mistaken for a finished one —
which matters most for what this screen is actually for, which is copying a
build artefact onto a server and then running it. A destination that already
exists is refused outright rather than overwritten: the queue has no way to
ask, and silently replacing a file somebody's process is serving is the worse
of the two failures. The remote pane has DELETE and MKDIR so that refusal is
not a dead end. A test against the container pins the assumption underneath all
of this — that SFTP's rename does not clobber.

Resume works within a run of the application and not across a restart, and the
limit is deliberate rather than unfinished. Nothing records which source wrote
a part file, and resuming one on the strength of its name matching is how a
corrupt artefact gets delivered with nothing reporting a failure; a part file
found at startup is started over. Making it survive a restart needs the
preferences store this client still has not got. The offset a resume starts at
is the part file's own length rather than the transfer's recorded progress: a
cancellation can land between a write completing and the counter moving, and
only one of those two is a fact about the bytes that are there.

The queue and its connection outlive a lock, as shells do. LockAsync already
argues that locking must not destroy work in flight — it is what somebody does
when they walk away from the machine, which is exactly when a long transfer is
most likely to be running — so TransfersViewModel is created once and the vault
is attached on unlock and detached on lock. What locking takes is the host
list, and it has to: those rows carry decrypted secrets.

DodoSSH.Client.Transfer is a new project rather than more of Client.Ssh. The
two answer different questions — one is about reaching a host, the other about
moving bytes and what to do when moving them stops halfway — and this is the
only client project that deliberately touches the local filesystem.

Three defects the tests found, none of which review would have. SftpPath.Name
answered an empty string for the root. NavigateRemoteAsync wrapped itself in
the busy guard, so navigating from inside another command did nothing at all
and the remote pane simply stayed empty after connecting, with no failure
anywhere to explain it. And opening an SFTP session per test made two
handshakes per test — this client learns a host key by being refused — which
pushed the SSH assembly past sshd's MaxStartups and failed a different few
unrelated tests each run; the session is shared through the fixture now, with
the reason written where the next person will hit it.

1004 tests green across 18 projects, 24 of them new: the SFTP subsystem against
the OpenSSH container, the queue against a real temporary directory and a fake
host, and three more layout measurements because a screen this window has never
laid out is a screen never checked.

Not verified: the screen has not been looked at running. The layout harness
measures it at the window's minimum in three shapes, which is the class of
defect that has shipped here before, but reaching it in the application needs
the compose stack, the migrations, the API and a browser sign-in. What is still
absent — the status bar's transfer count, dragging between the panes,
transferring a directory, and sftp over a bastion — is in
docs/design-import-gaps.md.
This commit is contained in:
2026-07-31 11:07:29 +02:00
parent 94e11f5e38
commit 04faef6597
32 changed files with 4933 additions and 53 deletions
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
The transfer engine: a queue, one transfer at a time, and the local half of a two-pane file
browser. Its own project rather than more of DodoSSH.Client.Ssh, because the two answer
different questions — that one is about reaching a host, this one is about moving bytes and
what to do when moving them stops halfway — and because this is the only client project that
deliberately touches the local filesystem.
Avalonia-free, like every client project but App. The queue is driven by tests against a real
temporary directory and a fake SFTP session, with no UI thread anywhere.
-->
<ItemGroup>
<ProjectReference Include="../DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="DodoSSH.Client.Transfer.Tests" />
</ItemGroup>
</Project>
@@ -0,0 +1,870 @@
using System.Buffers;
using DodoSSH.Client.Ssh;
namespace DodoSSH.Client.Transfer;
/// <summary>Which way the bytes are going.</summary>
public enum TransferDirection
{
/// <summary>From the host to this machine.</summary>
Download = 0,
/// <summary>From this machine to the host.</summary>
Upload = 1,
}
/// <summary>Where one transfer has got to.</summary>
public enum TransferState
{
/// <summary>Waiting for the one in front of it.</summary>
Queued = 0,
/// <summary>Moving bytes.</summary>
Running = 1,
/// <summary>Finished, and the file is at its final name.</summary>
Completed = 2,
/// <summary>Stopped by a failure, which <see cref="TransferSnapshot.Failure"/> describes.</summary>
Failed = 3,
/// <summary>Stopped because the user asked.</summary>
Cancelled = 4,
}
/// <summary>
/// One transfer, as the queue last saw it.
/// </summary>
/// <remarks>
/// A snapshot rather than a live object, because the queue mutates its entries from whichever thread the
/// pump is on and the interface reads them from the UI thread. Handing out an immutable record per change is
/// what lets the view model be a plain list of values with no locking of its own.
/// </remarks>
/// <param name="Id">Identifies the transfer for <see cref="FileTransferQueue.Cancel"/> and the rest.</param>
/// <param name="Direction">Which way it is going.</param>
/// <param name="Name">The file's own name, which is what a queue row is headed with.</param>
/// <param name="LocalPath">The local end, whichever end that is.</param>
/// <param name="RemotePath">The remote end.</param>
/// <param name="Length">
/// How many bytes there are in total, as the side that has the file reported when it was enqueued.
/// </param>
/// <param name="Transferred">How many have moved, resumed bytes included.</param>
/// <param name="State">Where it has got to.</param>
/// <param name="BytesPerSecond">Recent throughput, or zero when nothing is moving.</param>
/// <param name="Failure">Why it stopped, when it stopped badly.</param>
public sealed record TransferSnapshot(
Guid Id,
TransferDirection Direction,
string Name,
string LocalPath,
string RemotePath,
long Length,
long Transferred,
TransferState State,
double BytesPerSecond,
string? Failure)
{
/// <summary>How far along, between 0 and 1.</summary>
/// <remarks>
/// Zero for an empty file rather than one. A zero-byte transfer is finished the moment it starts and its
/// row says <see cref="TransferState.Completed"/>; a progress bar that filled instead would be the one
/// case where the bar told a different story from the state beside it.
/// </remarks>
public double Fraction => Length <= 0 ? 0 : Math.Clamp((double)Transferred / Length, 0, 1);
/// <summary>Whether this transfer will not move again on its own.</summary>
public bool IsFinished =>
State is TransferState.Completed or TransferState.Failed or TransferState.Cancelled;
/// <summary>
/// Whether there is a partial file to carry on from.
/// </summary>
/// <remarks>
/// Only for a transfer this queue itself left partial. See <see cref="FileTransferQueue.Retry"/> for why
/// a partial file found lying about is not resumed.
/// </remarks>
public bool CanResume =>
State is TransferState.Failed or TransferState.Cancelled && Transferred > 0 && Transferred < Length;
}
/// <summary>A transfer whose state or progress has moved.</summary>
/// <param name="transfer">The transfer, as it now is.</param>
public sealed class TransferChangedEventArgs(TransferSnapshot transfer) : EventArgs
{
/// <summary>The transfer.</summary>
public TransferSnapshot Transfer { get; } = transfer;
}
/// <summary>
/// The transfer queue: one file at a time, resumable, over one SFTP session.
/// </summary>
/// <remarks>
/// <para>
/// <b>One at a time.</b> Everything here shares one channel's window, so a second concurrent transfer does
/// not make the pair finish sooner — it makes both finish later, and it makes the progress of each
/// unreadable. A serial queue also means the throughput figure on a row is the throughput of the link, which
/// is the only reading of that number anybody acts on.
/// </para>
/// <para>
/// <b>Nothing is written at its final name until it is complete.</b> Every transfer goes to a part file
/// beside its destination and is renamed into place at the end, so an interrupted transfer can never be
/// mistaken for a finished one — which matters most for the thing people actually do with this screen, which
/// is copy a build artefact onto a server and then run it. A destination that already exists is refused
/// outright rather than overwritten: the queue has no way to ask, and silently replacing a file somebody
/// else's process is serving is the worse of the two failures.
/// </para>
/// <para>
/// <b>Resume is within a run of the application.</b> A part file left by an interrupted transfer is carried
/// on from by <see cref="Retry"/>, which knows the source it came from. A part file found at startup is not:
/// nothing here records what wrote it, and resuming a file from the middle on the strength of its name
/// matching is how a corrupted artefact gets delivered without anything reporting a failure. Making that
/// survive a restart needs somewhere to write the bookkeeping, which this client does not yet have — see
/// <c>docs/design-import-gaps.md</c> on the missing preferences store.
/// </para>
/// </remarks>
public sealed class FileTransferQueue : IAsyncDisposable
{
/// <summary>What an in-flight transfer's destination is called until it is finished.</summary>
/// <remarks>
/// Long and specific rather than the conventional <c>.part</c>, because this file appears in a directory
/// somebody else may be looking at — on a shared server, in a deployment directory — and a name that
/// says which program left it there is the difference between a question and an incident.
/// </remarks>
internal const string PartSuffix = ".dodossh-part";
/// <remarks>
/// The same 64 KiB the SFTP session reads in, so a copy is one read and one write per SFTP request with
/// no re-chunking in between.
/// </remarks>
private const int BufferSize = 64 * 1024;
/// <remarks>
/// Throughput over a window rather than since the start, because the number people read it for is "is it
/// still going, and how fast now" — an average since the start of a resumed multi-gigabyte transfer
/// answers a question nobody asked. Half a second is long enough that one slow request does not make it
/// jump and short enough that a stall shows up before it is worth investigating.
/// </remarks>
private static readonly TimeSpan ThroughputWindow = TimeSpan.FromMilliseconds(500);
/// <remarks>
/// A progress event per buffer would be some thousands a second on a fast link, each one marshalled to
/// the UI thread to move a bar by a pixel. Ten a second is smooth to a person and free to the window.
/// </remarks>
private static readonly TimeSpan ProgressInterval = TimeSpan.FromMilliseconds(100);
private readonly Func<CancellationToken, Task<ISftpSession>> sessions;
private readonly TimeProvider clock;
private readonly List<Entry> transfers = [];
private readonly Lock gate = new();
private readonly CancellationTokenSource lifetime = new();
private Task pump = Task.CompletedTask;
private bool disposed;
/// <param name="sessions">
/// Where the queue gets a session from, asked once per drain. A delegate rather than a session, because
/// the screen owns the connection and may have re-established it since the last transfer ran — and a
/// queue holding a stale session would fail every row with a socket error instead of reconnecting.
/// </param>
/// <param name="clock">Time source, so throughput is measurable without waiting for real seconds.</param>
public FileTransferQueue(Func<CancellationToken, Task<ISftpSession>> sessions, TimeProvider clock)
{
this.sessions = sessions;
this.clock = clock;
}
/// <summary>
/// Raised whenever a transfer's state or progress changes.
/// </summary>
/// <remarks>
/// <b>Raised on the pump's thread</b>, which is a thread-pool thread. A handler that touches an
/// observable collection has to marshal; this project has no toolkit to do it with, which is exactly why
/// it does not try. The same arrangement as <c>TerminalWorkspace.SessionEnded</c>.
/// </remarks>
public event EventHandler<TransferChangedEventArgs>? Changed;
/// <summary>Every transfer this queue knows about, in the order they were added.</summary>
public IReadOnlyList<TransferSnapshot> Snapshot()
{
lock (gate)
{
return [.. transfers.Select(Describe)];
}
}
/// <summary>Whether anything is queued or running.</summary>
public bool IsBusy
{
get
{
lock (gate)
{
return transfers.Exists(entry =>
entry.State is TransferState.Queued or TransferState.Running);
}
}
}
/// <summary>
/// Adds a transfer and starts the queue if it is not already running.
/// </summary>
/// <param name="direction">Which way.</param>
/// <param name="localPath">The local end. For a download this is the file to create.</param>
/// <param name="remotePath">The remote end. For an upload this is the file to create.</param>
/// <param name="length">
/// How many bytes there are, from the listing on the side that already has the file. Taken as given
/// rather than measured here, because the pane the user dragged from has just read it and asking again
/// would be a round trip to learn something already on screen.
/// </param>
/// <returns>The transfer's id.</returns>
public Guid Enqueue(
TransferDirection direction,
string localPath,
string remotePath,
long length)
{
ArgumentException.ThrowIfNullOrEmpty(localPath);
ArgumentException.ThrowIfNullOrEmpty(remotePath);
var entry = new Entry
{
Id = Guid.CreateVersion7(),
Direction = direction,
LocalPath = localPath,
RemotePath = remotePath,
Name = direction is TransferDirection.Download
? SftpPath.Name(remotePath)
: Path.GetFileName(localPath),
Length = length,
State = TransferState.Queued,
};
lock (gate)
{
ObjectDisposedException.ThrowIf(disposed, this);
transfers.Add(entry);
EnsurePumping();
}
Announce(entry);
return entry.Id;
}
/// <summary>
/// Stops a transfer, or takes a queued one out of the queue.
/// </summary>
/// <remarks>
/// The part file is left where it is, which is what makes <see cref="Retry"/> a resume rather than a
/// restart. <see cref="Discard"/> is how a row and its part file go.
/// </remarks>
public void Cancel(Guid id)
{
Entry? cancelled = null;
CancellationTokenSource? running = null;
lock (gate)
{
if (Find(id) is not { } entry)
{
return;
}
switch (entry.State)
{
case TransferState.Running:
// Marked by the run itself when the cancellation lands, so a transfer that was already
// finishing is not relabelled after the fact.
running = entry.Cancellation;
break;
case TransferState.Queued:
entry.State = TransferState.Cancelled;
cancelled = entry;
break;
default:
break;
}
}
running?.Cancel();
if (cancelled is not null)
{
Announce(cancelled);
}
}
/// <summary>
/// Puts a stopped transfer back in the queue, carrying on from where it stopped.
/// </summary>
/// <remarks>
/// Resumes rather than restarts, and only because this object watched the part file being written: it
/// knows which source those bytes came from. That is the whole of the bookkeeping the design's "resume
/// supported" needs, and the reason it does not survive a restart — see the remark on this type.
/// </remarks>
public void Retry(Guid id)
{
Entry? retried = null;
lock (gate)
{
ObjectDisposedException.ThrowIf(disposed, this);
if (Find(id) is not { State: TransferState.Failed or TransferState.Cancelled } entry)
{
return;
}
entry.State = TransferState.Queued;
entry.Failure = null;
entry.Resume = entry.Transferred > 0;
retried = entry;
EnsurePumping();
}
Announce(retried);
}
/// <summary>
/// Removes one finished transfer, and whatever it left on disk.
/// </summary>
/// <remarks>
/// Named for the destruction rather than for the tidying. A cancelled transfer's part file holds real
/// bytes that took real time to move, and the row is the only thing that knows the part file exists — so
/// removing the row without removing the file would leave litter nobody could attribute, and removing it
/// silently under a word like "clear" would throw away a resumable transfer without saying so.
/// </remarks>
/// <returns>Whether there was such a transfer to discard.</returns>
public async Task<bool> DiscardAsync(Guid id, CancellationToken cancellationToken)
{
Entry? discarded;
lock (gate)
{
if (Find(id) is not { } entry || !IsFinished(entry.State))
{
return false;
}
transfers.Remove(entry);
discarded = entry;
}
await RemovePartFileAsync(discarded, cancellationToken).ConfigureAwait(false);
return true;
}
/// <summary>Removes every completed transfer, which have nothing left on disk to clean up.</summary>
/// <returns>How many rows went.</returns>
public int ClearCompleted()
{
lock (gate)
{
return transfers.RemoveAll(entry => entry.State is TransferState.Completed);
}
}
/// <inheritdoc />
/// <remarks>
/// Cancels whatever is running and waits for it, rather than abandoning the pump. A transfer in flight
/// holds an open remote file and an open local one, and letting the process move on while they are still
/// being written is how a part file ends up longer than the bytes that reached it.
/// </remarks>
public async ValueTask DisposeAsync()
{
Task running;
lock (gate)
{
if (disposed)
{
return;
}
disposed = true;
running = pump;
}
await lifetime.CancelAsync().ConfigureAwait(false);
try
{
await running.ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// The pump reports failures onto the rows it was running; a fault escaping here would be one
// nothing is left to show.
}
lifetime.Dispose();
}
/// <remarks>
/// Started under the gate and restarted whenever it has finished, which is what makes "one at a time"
/// true without a dedicated thread waiting on an empty queue for the life of the application.
/// </remarks>
private void EnsurePumping()
{
if (pump.IsCompleted && !disposed)
{
pump = Task.Run(() => PumpAsync(lifetime.Token), lifetime.Token);
}
}
private async Task PumpAsync(CancellationToken cancellationToken)
{
while (TakeNext() is { } entry)
{
if (cancellationToken.IsCancellationRequested)
{
Stop(entry, TransferState.Cancelled, failure: null);
continue;
}
ISftpSession session;
try
{
session = await sessions(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
Stop(entry, TransferState.Cancelled, failure: null);
continue;
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// The session is what every remaining row needs, so a failure to get one is reported on the
// row that asked and the next iteration asks again. Failing the whole queue would hide which
// transfer was affected behind a single message.
Stop(entry, TransferState.Failed, exception.Message);
continue;
}
await RunAsync(entry, session, cancellationToken).ConfigureAwait(false);
}
}
/// <summary>Takes the next queued transfer and marks it running.</summary>
private Entry? TakeNext()
{
Entry? next;
lock (gate)
{
next = transfers.Find(entry => entry.State is TransferState.Queued);
if (next is null)
{
return null;
}
next.State = TransferState.Running;
next.Cancellation = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
next.BytesPerSecond = 0;
// Reset unless this is a resume, so a retry from the start does not open with a bar most of the
// way along that then jumps back.
if (!next.Resume)
{
next.Transferred = 0;
}
}
Announce(next);
return next;
}
private async Task RunAsync(Entry entry, ISftpSession session, CancellationToken cancellationToken)
{
var token = entry.Cancellation?.Token ?? cancellationToken;
try
{
if (entry.Direction is TransferDirection.Download)
{
await DownloadAsync(entry, session, token).ConfigureAwait(false);
}
else
{
await UploadAsync(entry, session, token).ConfigureAwait(false);
}
Stop(entry, TransferState.Completed, failure: null);
}
catch (OperationCanceledException)
{
Stop(entry, TransferState.Cancelled, failure: null);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
Stop(entry, TransferState.Failed, exception.Message);
}
finally
{
CancellationTokenSource? source;
lock (gate)
{
source = entry.Cancellation;
entry.Cancellation = null;
}
source?.Dispose();
}
}
private async Task DownloadAsync(Entry entry, ISftpSession session, CancellationToken cancellationToken)
{
var destination = entry.LocalPath;
if (File.Exists(destination))
{
throw new IOException(
$"{Path.GetFileName(destination)} is already in that folder. Rename or remove it first — "
+ "nothing here overwrites a file you already have.");
}
var part = destination + PartSuffix;
var offset = ResumableLength(entry, new FileInfo(part));
if (offset == 0 && File.Exists(part))
{
// A part file this transfer is not resuming from. It belongs to an earlier attempt at the same
// destination, and starting a fresh transfer by appending to it would produce a file that is
// longer than the source and wrong in the middle.
File.Delete(part);
}
await ReadIntoPartAsync(entry, session, part, offset, cancellationToken).ConfigureAwait(false);
// Only now, with both handles closed — which is what makes the copy its own method rather than a
// block here. On Windows a move of a file still open for writing fails, and it is the one step whose
// failure would leave a complete transfer looking like an incomplete one.
File.Move(part, destination);
}
private async Task ReadIntoPartAsync(
Entry entry,
ISftpSession session,
string part,
long offset,
CancellationToken cancellationToken)
{
var remote = await session
.OpenReadAsync(entry.RemotePath, offset, cancellationToken)
.ConfigureAwait(false);
await using var remoteScope = remote.ConfigureAwait(false);
var local = new FileStream(
part,
offset == 0 ? FileMode.Create : FileMode.Open,
FileAccess.Write,
FileShare.None,
BufferSize,
useAsync: true);
await using var localScope = local.ConfigureAwait(false);
local.Seek(offset, SeekOrigin.Begin);
await CopyAsync(remote, local, entry, offset, cancellationToken).ConfigureAwait(false);
}
private async Task UploadAsync(Entry entry, ISftpSession session, CancellationToken cancellationToken)
{
var destination = entry.RemotePath;
if (await session.StatAsync(destination, cancellationToken).ConfigureAwait(false) is not null)
{
throw new IOException(
$"{SftpPath.Name(destination)} is already in that directory on the host. Rename or remove it "
+ "first — nothing here overwrites a file that is already there.");
}
var part = destination + PartSuffix;
var existing = await session.StatAsync(part, cancellationToken).ConfigureAwait(false);
var offset = ResumableLength(entry, existing);
if (offset == 0 && existing is not null)
{
await session.DeleteAsync(part, cancellationToken).ConfigureAwait(false);
}
await WriteIntoPartAsync(entry, session, part, offset, cancellationToken).ConfigureAwait(false);
await session.RenameAsync(part, destination, cancellationToken).ConfigureAwait(false);
}
private async Task WriteIntoPartAsync(
Entry entry,
ISftpSession session,
string part,
long offset,
CancellationToken cancellationToken)
{
var local = new FileStream(
entry.LocalPath, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, useAsync: true);
await using var localScope = local.ConfigureAwait(false);
var remote = await session.OpenWriteAsync(part, offset, cancellationToken).ConfigureAwait(false);
await using var remoteScope = remote.ConfigureAwait(false);
local.Seek(offset, SeekOrigin.Begin);
await CopyAsync(local, remote, entry, offset, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// How far a resume may start, given what is actually on the destination.
/// </summary>
/// <remarks>
/// The part file's own length, not the transfer's recorded progress, and never longer than the source.
/// The two can disagree — a cancellation lands between a write completing and the counter moving, and a
/// buffered write may not have reached the disk at all — and only one of them is a fact about the bytes
/// that are there. Trusting the counter would resume past bytes that were never written, which is the
/// one way a resumed transfer can produce a corrupt file that nothing reports.
/// </remarks>
private static long ResumableLength(Entry entry, FileInfo part) =>
part.Exists ? ResumableLength(entry, part.Length) : 0;
private static long ResumableLength(Entry entry, SftpEntry? part) =>
part is null ? 0 : ResumableLength(entry, part.Length);
private static long ResumableLength(Entry entry, long partLength)
{
if (!entry.Resume || partLength <= 0 || partLength >= entry.Length)
{
// A part file at or beyond the source's length is not a resume point; it is evidence that the
// source changed under a previous attempt. Starting again is the only answer that ends with the
// right bytes.
return 0;
}
return partLength;
}
private async Task CopyAsync(
Stream source,
Stream destination,
Entry entry,
long startOffset,
CancellationToken cancellationToken)
{
var buffer = ArrayPool<byte>.Shared.Rent(BufferSize);
var meter = new ProgressMeter(clock, startOffset);
try
{
var transferred = startOffset;
while (true)
{
var read = await source
.ReadAsync(buffer.AsMemory(0, BufferSize), cancellationToken)
.ConfigureAwait(false);
if (read == 0)
{
break;
}
await destination
.WriteAsync(buffer.AsMemory(0, read), cancellationToken)
.ConfigureAwait(false);
transferred += read;
if (Record(entry, meter, transferred))
{
Announce(entry);
}
}
// Flushed before the caller closes the handles and renames, so a write still sitting in a buffer
// is not counted as delivered by a rename that beat it to the disk.
await destination.FlushAsync(cancellationToken).ConfigureAwait(false);
lock (gate)
{
entry.Transferred = transferred;
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
/// <summary>Writes progress onto the entry, and says whether it is worth telling anyone.</summary>
private bool Record(Entry entry, ProgressMeter meter, long transferred)
{
var reading = meter.Read(transferred);
lock (gate)
{
entry.Transferred = transferred;
if (reading.BytesPerSecond is { } rate)
{
entry.BytesPerSecond = rate;
}
}
return reading.WorthAnnouncing;
}
private void Stop(Entry entry, TransferState state, string? failure)
{
lock (gate)
{
entry.State = state;
entry.Failure = failure;
entry.BytesPerSecond = 0;
entry.Resume = false;
}
Announce(entry);
}
private async Task RemovePartFileAsync(Entry entry, CancellationToken cancellationToken)
{
try
{
if (entry.Direction is TransferDirection.Download)
{
File.Delete(entry.LocalPath + PartSuffix);
return;
}
var session = await sessions(cancellationToken).ConfigureAwait(false);
var part = entry.RemotePath + PartSuffix;
if (await session.StatAsync(part, cancellationToken).ConfigureAwait(false) is not null)
{
await session.DeleteAsync(part, cancellationToken).ConfigureAwait(false);
}
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// Best effort, and deliberately silent. The row the user asked to remove is already gone, and a
// remote part file that outlives it is litter rather than a failure — reporting it would mean
// putting an error on a screen for an operation that did what was asked.
}
}
private Entry? Find(Guid id) => transfers.Find(entry => entry.Id == id);
private static bool IsFinished(TransferState state) =>
state is TransferState.Completed or TransferState.Failed or TransferState.Cancelled;
private void Announce(Entry entry)
{
TransferSnapshot snapshot;
lock (gate)
{
snapshot = Describe(entry);
}
Changed?.Invoke(this, new TransferChangedEventArgs(snapshot));
}
/// <remarks>Callers hold <see cref="gate"/>: every field read here is written from the pump.</remarks>
private static TransferSnapshot Describe(Entry entry) => new(
entry.Id,
entry.Direction,
entry.Name,
entry.LocalPath,
entry.RemotePath,
entry.Length,
entry.Transferred,
entry.State,
entry.BytesPerSecond,
entry.Failure);
/// <summary>
/// Keeps the two clocks a copy loop needs: when throughput was last sampled, and when the interface was
/// last told anything.
/// </summary>
/// <remarks>
/// Its own type because both are stateful across iterations, and the alternative — four locals threaded
/// through the loop by reference — is how the sampling and the announcing end up sharing a timestamp and
/// quietly becoming one interval. They are deliberately different: half a second is the right window to
/// measure a rate over, and a tenth of a second is the right rate to repaint at.
/// </remarks>
private sealed class ProgressMeter(TimeProvider clock, long startOffset)
{
private long sampleAt = clock.GetTimestamp();
private long sampleBytes = startOffset;
private long announcedAt = clock.GetTimestamp();
/// <param name="transferred">Total bytes moved, resumed bytes included.</param>
/// <returns>
/// A new throughput figure when the window has elapsed and null when it has not, so a rate is never
/// recomputed from a sample too short to mean anything; and whether this is a moment to repaint.
/// </returns>
public (double? BytesPerSecond, bool WorthAnnouncing) Read(long transferred)
{
var now = clock.GetTimestamp();
var sinceSample = clock.GetElapsedTime(sampleAt, now);
double? rate = null;
if (sinceSample >= ThroughputWindow)
{
rate = (transferred - sampleBytes) / sinceSample.TotalSeconds;
sampleAt = now;
sampleBytes = transferred;
}
if (clock.GetElapsedTime(announcedAt, now) < ProgressInterval)
{
return (rate, false);
}
announcedAt = now;
return (rate, true);
}
}
/// <summary>One transfer's mutable state, which only the queue touches and only under its gate.</summary>
private sealed class Entry
{
public required Guid Id { get; init; }
public required TransferDirection Direction { get; init; }
public required string LocalPath { get; init; }
public required string RemotePath { get; init; }
public required string Name { get; init; }
public required long Length { get; init; }
public required TransferState State { get; set; }
public long Transferred { get; set; }
public double BytesPerSecond { get; set; }
public string? Failure { get; set; }
/// <summary>Whether the next run may carry on from a part file rather than starting again.</summary>
public bool Resume { get; set; }
public CancellationTokenSource? Cancellation { get; set; }
}
}
@@ -0,0 +1,126 @@
namespace DodoSSH.Client.Transfer;
/// <summary>One entry in a local directory.</summary>
/// <param name="Name">The entry's own name.</param>
/// <param name="FullPath">The absolute path.</param>
/// <param name="IsDirectory">Whether it can be navigated into.</param>
/// <param name="Length">Size in bytes, or zero for a directory.</param>
/// <param name="LastWriteTimeUtc">When it was last written.</param>
/// <remarks>
/// Deliberately the same shape as <c>SftpEntry</c> minus the permission string, because the two panes of the
/// file browser show the same columns and a local mode rendered in POSIX notation would be a fiction on
/// Windows — where the design's <c>PERMS</c> column has no honest value at all.
/// </remarks>
public sealed record LocalEntry(
string Name,
string FullPath,
bool IsDirectory,
long Length,
DateTimeOffset LastWriteTimeUtc);
/// <summary>
/// The local half of the file browser.
/// </summary>
/// <remarks>
/// <para>
/// The first <c>System.IO</c> in the client outside the cache and the device key, and it stays behind this
/// one type on purpose: everything above reasons about <see cref="LocalEntry"/>, so the transfers screen and
/// its view model never enumerate a directory themselves.
/// </para>
/// <para>
/// <b>Nothing here throws for one unreadable entry.</b> A local directory listing on Windows routinely
/// contains things the user cannot stat — a junction into another profile, a file another process holds
/// open — and a browser that failed the whole listing for one of them would be unable to show
/// <c>C:\Users</c>. Those entries are skipped; a directory that cannot be opened at all is still a failure,
/// because there is nothing to show.
/// </para>
/// </remarks>
public static class LocalDirectory
{
/// <summary>Where the local pane opens.</summary>
/// <remarks>
/// The user profile rather than the process's working directory, which for a desktop application is
/// wherever it happened to be launched from — an installation directory nobody keeps files in.
/// </remarks>
public static string Home =>
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile, Environment.SpecialFolderOption.None);
/// <summary>
/// Lists a directory, directories first and then by name.
/// </summary>
/// <exception cref="IOException">The directory could not be opened.</exception>
/// <exception cref="UnauthorizedAccessException">The directory could not be opened.</exception>
public static IReadOnlyList<LocalEntry> List(string path)
{
var directory = new DirectoryInfo(path);
var entries = new List<LocalEntry>();
// EnumerateFileSystemInfos rather than GetFileSystemInfos: the enumerating form yields entries as it
// reads them, so a directory of fifty thousand files does not have to be materialised twice.
foreach (var entry in directory.EnumerateFileSystemInfos())
{
try
{
var isDirectory = entry.Attributes.HasFlag(FileAttributes.Directory);
entries.Add(new LocalEntry(
entry.Name,
entry.FullName,
isDirectory,
isDirectory ? 0 : ((FileInfo)entry).Length,
new DateTimeOffset(entry.LastWriteTimeUtc, TimeSpan.Zero)));
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
// One entry this process cannot stat. Skipped rather than shown as a row with no facts on
// it, and skipped rather than failing the listing — see the remark on this type.
}
}
entries.Sort(static (left, right) => left.IsDirectory == right.IsDirectory
? string.Compare(left.Name, right.Name, StringComparison.CurrentCultureIgnoreCase)
: right.IsDirectory.CompareTo(left.IsDirectory));
return entries;
}
/// <summary>
/// The directory holding a path, or null when it is already a root.
/// </summary>
/// <remarks>
/// Null rather than the path itself, because the local pane's "up" has somewhere further to go than the
/// remote's does: above <c>C:\</c> is the list of drives, which is not a directory. The remote pane stops
/// at <c>/</c>, which is.
/// </remarks>
public static string? Parent(string path) => Path.GetDirectoryName(Path.TrimEndingDirectorySeparator(path));
/// <summary>
/// Where the local pane can start from: the drives on Windows, and the root elsewhere.
/// </summary>
/// <remarks>
/// Ready drives only. An empty optical drive or a disconnected network mapping is listed by
/// <see cref="DriveInfo.GetDrives"/> and throws on the first attempt to read it, which would put a row on
/// screen whose only behaviour is an error.
/// </remarks>
public static IReadOnlyList<string> Roots()
{
var roots = new List<string>();
foreach (var drive in DriveInfo.GetDrives())
{
try
{
if (drive.IsReady)
{
roots.Add(drive.RootDirectory.FullName);
}
}
catch (IOException)
{
// A drive that fails even to answer whether it is ready. Nothing to show.
}
}
return roots;
}
}
@@ -0,0 +1,54 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.137, )",
"resolved": "3.0.137",
"contentHash": "48n8WsokyefLOZt2qGPpk+hjfF9rQRa7MGK+8hMS6sO22QuIDtBztfbXV7RtVfX7JKjUwDI9e06I8aeCc5XZzQ=="
},
"Microsoft.CodeAnalysis.BannedApiAnalyzers": {
"type": "Direct",
"requested": "[5.6.0, )",
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "8.0.2",
"contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg=="
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "8.0.3",
"contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2"
}
},
"dodossh.client.ssh": {
"type": "Project",
"dependencies": {
"SSH.NET": "[2025.1.0, )"
}
},
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"SSH.NET": {
"type": "CentralTransitive",
"requested": "[2025.1.0, )",
"resolved": "2025.1.0",
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
"dependencies": {
"BouncyCastle.Cryptography": "2.6.2",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
}
}
}
}