Public Access
The commit before this one taught a terminal to notice its machine going away.
The file browser had the same defect and three of the same symptoms, and it is
also the screen people leave soonest — so the disconnect work and four things it
was missing arrive together, because they are the same complaint: this screen
knows less about the host than the host does.
◆ THE DISCONNECT HALF, WHICH IS THE OTHER SURFACE OF THE LAST COMMIT.
IsConnected was set at connect and cleared only by the DISCONNECT button. A host
that went away therefore left this screen saying CONNECTED over a session that
was gone, and every action after it failing with the same server message and
nothing saying why they all did.
IRemoteFileStore now carries a latched TransportFailure and a ConnectionLost
event. The latch is the shell's own — read ISshShellSession.TransportFailure for
the argument, which is unchanged. The event is not, and it is the part this
surface needs: a file browser is idle between operations, so without something
raised at the moment the transport fails, nobody finds out until they click. A
bucket implements both and raises neither, which is honest rather than a gap:
S3 is request-per-operation with no connection to lose.
- THE LISTING STAYS ON SCREEN, and that is the half that makes the rest worth
having. SftpEntry is a record of what the server said rather than a handle,
so the pane can go on showing where the user was — which is what RECONNECT
is offering to put them back into. That needed a new ShowsRemoteContents on
both heads: every "is there anything here" binding read IsConnected, which
was right while the only way to stop being connected was to press a button
that empties the pane, and wrong the moment a session could be lost.
- RECONNECT re-dials through the ordinary connect path — same host key gate,
same refusals, same log entry — and then walks back to the directory the
pane was showing. The path is read off RemotePath rather than captured,
because the pane already keeps it up to date and a copy is a second thing to
get wrong. A host wanting a typed password is asked for one rather than
dialled with an empty box.
- SessionEndedBar is now one control on both surfaces, with the shell
resolving which it is drawn over: a terminal has two endings to tell apart
and a keychain entry to check, a file session has one ending and already
captured what it would dial. The phone's Files screen has its own copy,
bound to TransfersViewModel directly, because its DataContext is that rather
than the shell.
◆ PERMISSIONS: THE PERMS COLUMN STOPS BEING READ-ONLY.
It has been a true fact about a file with nothing to be done about it since it
arrived, and the commonest next act after using this screen was opening a
terminal — a whole second authenticated connection — to chmod what had just been
uploaded.
Nine checkboxes rather than a box to type 755 into, because the mode is already
drawn as -rwxr-xr-x in the column the form was opened from: a who-can-do-what
grid is the same representation the reader is holding, where an octal field asks
them to translate something they can see into something they cannot check. The
octal is printed underneath as it changes, so the two readings check each other.
The EXEC column is headed "enter" over a directory, which is what that bit means
there and the commonest misreading of a POSIX mode. The listing is re-read after
APPLY, because a umask, an ACL or a filesystem mounted without permissions can
all land somewhere other than what was asked for.
Beside it, mode and mtime carried across a transfer — which is what makes an
uploaded deploy.sh arrive executable instead of with the server's umask on it.
THE MODE IS ONLY SENT FROM A PLATFORM THAT HAS ONE. A file on Windows has no
POSIX mode; this screen already says so by leaving the local pane's PERMS column
empty rather than filling it with a plausible -rw-r--r--, and inventing 0644, or
0755 for anything called .sh, would be this client deciding a fact about
somebody's file that nothing on the machine it came from ever said. The
timestamp goes either way, because every platform has one. A store with neither
refuses in its own words and is never asked.
◆ MULTI-SELECT: A TICK IS NOT THE LIST'S SELECTION.
The two answer different questions and the split is the whole design. The
selection is "the row I am looking at", which rename, move and permissions act
on — each takes a form naming one path and is one-at-a-time by construction. The
tick is "the rows I have gathered up", which download, upload and delete take,
and it survives the highlight moving.
With nothing ticked the batch falls back to the selection, which is what keeps
the commonest use of this screen from growing a step — the usual way a
multi-select makes a tool worse. Ticks are released once a set is queued, or a
second press sends everything again.
Deleting several names every path rather than a count, because agreeing to "3
items" is agreeing to a number, and this is the one act on this screen that
nothing walks back. Each goes on its own and a refusal does not stop the rest:
the host turns down a directory that still has something in it, and in a set of
five that must not silently cancel the four it would have removed — nor hide
which ones it did not.
A drawn checkbox rather than ctrl-click, and not for taste: the phone shares
this view model and has neither ctrl nor shift, so a multi-selection has to be a
tap target there whatever the desktop does. One shape on both heads, and a state
somebody can see.
◆ RECURSIVE FOLDERS: THE TREE BECOMES FILES BEFORE THE QUEUE SEES IT.
A directory used to be counted and skipped. It is now walked, and the queue
still knows nothing about trees — which is what keeps every property it has:
each file resumable on its own, each written to a part file and renamed into
place, none able to overwrite anything already there.
The plan is collected whole and then queued, because directories have to exist
before the files that go in them and shortest-first is the only order that
guarantees a parent before its child. It also means a walk refused halfway has
queued nothing yet, so the sentence it produces describes one outcome rather
than an outcome and a queue that half happened.
Links are counted, not followed, in both directions. One pointing at an ancestor
is a walk with no end, and on the remote side there is a second reason: an lstat
listing says a name is a link and not what it leads to, so descending would be a
round trip per entry to find out. Locally that is AttributesToSkip on the one
enumeration that decides what to descend into. A 5000-file ceiling is a backstop
for the accidental drag of a home directory, and it says so rather than
truncating quietly.
The status line grew four more ways to be partly what was asked for, and states
each: gone, refused, left alone, not a file.
◆ EDITING IN PLACE: FETCH, OPEN, WATCH, PUSH BACK.
The round trip this replaces is download, find it, open it, edit, come back,
delete the old one because nothing here overwrites, upload, check the mode
survived.
DELIBERATELY NOT THROUGH THE TRANSFER QUEUE, in either direction. The queue
refuses to overwrite, which is the right rule for a file somebody dragged and
exactly the wrong one for a file they are editing, where replacing the original
is the entire intent. The write-back deletes first: OpenWriteAsync at offset
zero writes over the beginning and leaves any tail that is longer — its own
contract says so — which for a config that got shorter means a file ending in
the back half of its previous version. Deleting is the only truncation this
interface offers, and the mode goes back on afterwards because deleting took it.
A stat before every save, compared against what the server said when the copy
was taken, catches somebody else writing while the file is open — which is how a
config gets silently reverted. There is no merge and no button pretending
otherwise; the three answers offered are the three that exist. Length and
timestamp rather than a digest, because a digest is a second full read of a file
there is otherwise no reason to fetch.
Both platform halves are injected: what opens a .conf is a registry
association, a desktop entry or a Launch Services binding depending on the
desktop, and watching a file is a platform object with its own thread. The
watcher watches the directory and filters the name, because a great many editors
write a temporary file and rename it over the top — which to a watcher pointed
at an inode is the file disappearing. The phone gets neither delegate, so the
button is not live there rather than there and disappointing.
◆ TWO THINGS FOUND ON THE WAY, BOTH WORTH STATING.
The shell suite's inline `post` was not serialising the way a dispatcher does.
Routing TransfersViewModel through it exposed a check-then-add in
OnTransferChanged that two threads turn into two rows for one transfer. Harmless
in production, where the UI thread serialises — the fixture was under-specifying
what it stood in for, and now posts under a lock.
And the edit session replaced its own record with `with` on each save, which
left the watcher holding an instance that was no longer the current one: every
save after the first quietly did nothing. The stamp moves in place now, and the
test saves twice.
◆ WHAT IS VERIFIED.
901 across the five suites this touches, 19 of them new. The layout suite earned
its keep three times over: the batch deletion card's path list overflowed the
204-pixel pane at 120, at 72 and at 56 before 32 held, and the edit strip
overflowed by 131 until the whole strip — rather than the log inside it — was
bounded. Each number in those files is measured and says which failure it came
from, because the next person to add a line to either card will move them again.
What no suite covers is the platform halves of editing: Process.Start with
UseShellExecute and a live FileSystemWatcher are the operating system, and a test
waiting on one would be measuring it. The shell's own half — when a save is
written back, when it is refused, what stopping releases — is covered against a
watcher that fires on demand.
422 lines
16 KiB
C#
422 lines
16 KiB
C#
using DodoSSH.Client.Ssh;
|
|
|
|
namespace DodoSSH.Client.App.Tests;
|
|
|
|
/// <summary>
|
|
/// An SSH stack that connects to nothing.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The shell suite is about what the view models do, and the real factory would need a reachable
|
|
/// sshd — which <c>DodoSSH.Client.Ssh.Tests</c> already covers against a container. What this makes
|
|
/// testable is everything the connect path does <em>around</em> the connection.
|
|
/// </remarks>
|
|
internal sealed class FakeSshConnectionFactory : ISshConnectionFactory, ISftpSessionFactory
|
|
{
|
|
/// <summary>Thrown instead of connecting, when set. Used for the host-key paths.</summary>
|
|
internal Exception? Failure { get; set; }
|
|
|
|
/// <summary>
|
|
/// Holds a connection open until it is completed, when set.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A handshake takes as long as a network takes, and connecting is deliberately no longer allowed to
|
|
/// hold the window still while it does — so there is now behaviour that only exists <em>during</em> a
|
|
/// connection: a tab in the strip with no session behind it. This is how a test gets to look at that
|
|
/// moment rather than at the two on either side of it.
|
|
/// </remarks>
|
|
internal TaskCompletionSource? Gate { get; set; }
|
|
|
|
/// <summary>Requests this factory was asked for, in order.</summary>
|
|
internal List<SshConnectionRequest> Requests { get; } = [];
|
|
|
|
/// <summary>Every shell this factory has opened, in order.</summary>
|
|
/// <remarks>
|
|
/// Exists so a test can reach one and drop it — see <see cref="FakeSshShellSession.Drop"/>. There was no
|
|
/// reason to hold them while the only ending a session had was the tab being closed, which the shell can
|
|
/// ask for directly.
|
|
/// </remarks>
|
|
internal List<FakeSshShellSession> Shells { get; } = [];
|
|
|
|
/// <summary>Requests for a file-transfer session, in order.</summary>
|
|
/// <remarks>
|
|
/// Kept apart from <see cref="Requests"/> deliberately: file transfer is a separate connection, and a
|
|
/// test asserting that opening a terminal did not also open one would have nothing to look at if the two
|
|
/// shared a list.
|
|
/// </remarks>
|
|
internal List<SshConnectionRequest> SftpRequests { get; } = [];
|
|
|
|
/// <inheritdoc />
|
|
public async Task<ISshConnection> ConnectAsync(
|
|
SshConnectionRequest request,
|
|
IProgress<SshConnectionPhase>? progress,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
Requests.Add(request);
|
|
|
|
// Before the gate rather than after it, which is what makes this fake useful for the connecting
|
|
// card: a test that holds Gate open is a connection stuck partway through, and the step list has to
|
|
// show it stuck on a named step rather than on none.
|
|
progress?.Report(SshConnectionPhase.Reaching);
|
|
|
|
if (Gate is { } gate)
|
|
{
|
|
await gate.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
if (Failure is { } failure)
|
|
{
|
|
throw failure;
|
|
}
|
|
|
|
// Only on the way to succeeding. A failure reported as having authenticated would let a test pass
|
|
// while the card showed a refused connection getting one step further than it did.
|
|
progress?.Report(SshConnectionPhase.CheckingHostKey);
|
|
progress?.Report(SshConnectionPhase.Authenticating);
|
|
|
|
return new FakeSshConnection(request, Shells);
|
|
}
|
|
|
|
/// <summary>The file-transfer session this factory last handed out, or null when it never has.</summary>
|
|
/// <remarks>
|
|
/// Kept so a test can read back what the screen actually asked the host to do — which path was renamed,
|
|
/// which was deleted, which directories were listed. The screen reports all three in its own status
|
|
/// line, and a suite that asserted only on that would be checking a sentence this code wrote about
|
|
/// itself rather than the operation it claims to have performed.
|
|
/// </remarks>
|
|
internal FakeSftpSession? OpenedSftp { get; private set; }
|
|
|
|
/// <inheritdoc />
|
|
public Task<ISftpSession> OpenSftpAsync(
|
|
SshConnectionRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SftpRequests.Add(request);
|
|
|
|
if (Failure is { } failure)
|
|
{
|
|
return Task.FromException<ISftpSession>(failure);
|
|
}
|
|
|
|
OpenedSftp = new FakeSftpSession(request);
|
|
|
|
return Task.FromResult<ISftpSession>(OpenedSftp);
|
|
}
|
|
}
|
|
|
|
/// <summary>A remote filesystem with one directory in it.</summary>
|
|
/// <remarks>
|
|
/// Enough for the shell suite, which is about what the screen does around a session rather than about
|
|
/// moving bytes. The queue's own behaviour is covered against a fuller fake in
|
|
/// <c>DodoSSH.Client.Transfer.Tests</c>, and the real subsystem against a container in
|
|
/// <c>DodoSSH.Client.Ssh.Tests</c>.
|
|
/// </remarks>
|
|
internal sealed class FakeSftpSession(SshConnectionRequest request) : ISftpSession
|
|
{
|
|
/// <inheritdoc />
|
|
public bool IsConnected { get; private set; } = true;
|
|
|
|
/// <inheritdoc />
|
|
public string? TransportFailure { get; private set; }
|
|
|
|
/// <inheritdoc />
|
|
public event EventHandler? ConnectionLost;
|
|
|
|
/// <summary>
|
|
/// Ends this session the way a rebooted host does: the connection is gone and it says so.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Latched before the announcement, which is the ordering the real session promises — a screen reading
|
|
/// the reason out of the event would see null if a fake did it the other way round.
|
|
/// </remarks>
|
|
internal void Drop(string reason)
|
|
{
|
|
TransportFailure = reason;
|
|
IsConnected = false;
|
|
|
|
ConnectionLost?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public HostKeyPresentation HostKey { get; } =
|
|
new(request.Host, request.Port, "ssh-ed25519", "SHA256:fake");
|
|
|
|
/// <inheritdoc />
|
|
public string Cipher { get; } = "aes256-gcm@openssh.com";
|
|
|
|
/// <inheritdoc />
|
|
public string HomeDirectory => $"/home/{request.Username}";
|
|
|
|
/// <summary>Every directory this session has been asked to list, in order.</summary>
|
|
/// <remarks>
|
|
/// The evidence that a refresh happened at all. A listing whose contents never change — this one always
|
|
/// answers the same single row — leaves no other trace of having been re-read, which is exactly the
|
|
/// thing the automatic refresh after a transfer has to be proved to do.
|
|
/// </remarks>
|
|
internal List<string> Listed { get; } = [];
|
|
|
|
/// <summary>Every rename this session has been asked for, as it was asked.</summary>
|
|
internal List<(string From, string To)> Renamed { get; } = [];
|
|
|
|
/// <summary>Every path this session has been asked to delete.</summary>
|
|
internal List<string> Deleted { get; } = [];
|
|
|
|
/// <summary>Every mode this session has been asked to set, as it was asked.</summary>
|
|
/// <remarks>
|
|
/// The evidence the screen's own status line cannot be: that sentence is code describing itself, and
|
|
/// what matters is which nine bits actually went to the host.
|
|
/// </remarks>
|
|
internal List<(string Path, int Mode)> PermissionsSet { get; } = [];
|
|
|
|
/// <summary>Every timestamp this session has been asked to set.</summary>
|
|
internal List<(string Path, DateTimeOffset When)> TimesSet { get; } = [];
|
|
|
|
/// <inheritdoc />
|
|
public Task SetPermissionsAsync(string path, int mode, CancellationToken cancellationToken)
|
|
{
|
|
PermissionsSet.Add((path, mode));
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task SetLastWriteTimeAsync(
|
|
string path,
|
|
DateTimeOffset lastWriteTimeUtc,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
TimesSet.Add((path, lastWriteTimeUtc));
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken)
|
|
{
|
|
Listed.Add(path);
|
|
|
|
return Task.FromResult<IReadOnlyList<SftpEntry>>(
|
|
[
|
|
new SftpEntry(
|
|
"notes.txt",
|
|
SftpPath.Combine(path, "notes.txt"),
|
|
SftpEntryKind.File,
|
|
12,
|
|
DateTimeOffset.UnixEpoch,
|
|
"-rw-r--r--"),
|
|
]);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Paths this session admits to already having, and what it says about each.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// ◆ Empty by default, and that is load-bearing rather than lazy: the transfer queue asks
|
|
/// <see cref="StatAsync"/> whether a destination is taken and refuses to overwrite one that is, so a
|
|
/// fake answering yes for everything would make every upload in this suite fail. What needs a real
|
|
/// answer is editing, which stats the file before each write-back to see whether it moved — so a test
|
|
/// about that puts the one path it is about in here.
|
|
/// </remarks>
|
|
internal Dictionary<string, SftpEntry> Existing { get; } = new(StringComparer.Ordinal);
|
|
|
|
/// <summary>What the last write-back actually sent, so an edit's bytes are observable.</summary>
|
|
internal byte[]? Written { get; private set; }
|
|
|
|
/// <inheritdoc />
|
|
public Task<SftpEntry?> StatAsync(string path, CancellationToken cancellationToken) =>
|
|
Task.FromResult(Existing.TryGetValue(path, out var entry) ? entry : null);
|
|
|
|
/// <inheritdoc />
|
|
public Task<Stream> OpenReadAsync(string path, long offset, CancellationToken cancellationToken) =>
|
|
Task.FromResult<Stream>(new MemoryStream("hello there\n"u8.ToArray(), writable: false));
|
|
|
|
/// <inheritdoc />
|
|
/// <remarks>
|
|
/// ◆ A write updates <see cref="Existing"/> only for a path already in it, which is the difference
|
|
/// between modelling a server and breaking every other test in the suite: the transfer queue writes to
|
|
/// part files and stats them to decide whether there is anything to resume from, so a fake that
|
|
/// remembered every write would start answering yes to questions it currently answers no to. What is in
|
|
/// <see cref="Existing"/> is what a test deliberately put there.
|
|
/// </remarks>
|
|
public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken) =>
|
|
Task.FromResult<Stream>(new RecordingStream(bytes =>
|
|
{
|
|
Written = bytes;
|
|
|
|
if (Existing.TryGetValue(path, out var before))
|
|
{
|
|
Existing[path] = before with
|
|
{
|
|
Length = bytes.Length,
|
|
LastWriteTimeUtc = before.LastWriteTimeUtc.AddSeconds(1),
|
|
};
|
|
}
|
|
}));
|
|
|
|
/// <summary>Every directory this session was asked to make, in the order it was asked.</summary>
|
|
/// <remarks>
|
|
/// The evidence a folder upload actually built the tree before it queued the files. Order matters and is
|
|
/// asserted on: a parent has to exist before its child is asked for, and shortest-first is what
|
|
/// guarantees it.
|
|
/// </remarks>
|
|
internal List<string> Created { get; } = [];
|
|
|
|
/// <inheritdoc />
|
|
public Task CreateDirectoryAsync(string path, CancellationToken cancellationToken)
|
|
{
|
|
Created.Add(path);
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task DeleteAsync(string path, CancellationToken cancellationToken)
|
|
{
|
|
Deleted.Add(path);
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken)
|
|
{
|
|
Renamed.Add((fromPath, toPath));
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public ValueTask DisposeAsync()
|
|
{
|
|
IsConnected = false;
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
}
|
|
|
|
internal sealed class FakeSshConnection(SshConnectionRequest request, List<FakeSshShellSession> shells)
|
|
: ISshConnection
|
|
{
|
|
/// <inheritdoc />
|
|
public bool IsConnected { get; private set; } = true;
|
|
|
|
/// <inheritdoc />
|
|
public HostKeyPresentation HostKey { get; } =
|
|
new(request.Host, request.Port, "ssh-ed25519", "SHA256:fake");
|
|
|
|
/// <inheritdoc />
|
|
public string Cipher { get; } = "aes256-gcm@openssh.com";
|
|
|
|
/// <inheritdoc />
|
|
public Task<ISshShellSession> OpenShellAsync(
|
|
TerminalSize size,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var shell = new FakeSshShellSession();
|
|
shells.Add(shell);
|
|
|
|
return Task.FromResult<ISshShellSession>(shell);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public ValueTask DisposeAsync()
|
|
{
|
|
IsConnected = false;
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
}
|
|
|
|
/// <summary>A write stream that hands its contents over when it is closed.</summary>
|
|
/// <remarks>
|
|
/// A <see cref="MemoryStream"/> cannot be read after disposal, and disposal is exactly when a write is
|
|
/// finished — so a fake that handed one back could observe a transfer starting and never what it sent.
|
|
/// </remarks>
|
|
internal sealed class RecordingStream(Action<byte[]> onClosed) : MemoryStream
|
|
{
|
|
private bool handed;
|
|
|
|
/// <inheritdoc />
|
|
protected override void Dispose(bool disposing)
|
|
{
|
|
if (disposing && !handed)
|
|
{
|
|
handed = true;
|
|
onClosed(ToArray());
|
|
}
|
|
|
|
base.Dispose(disposing);
|
|
}
|
|
}
|
|
|
|
/// <summary>A shell that is open, silent and closes only when it is told to.</summary>
|
|
/// <remarks>
|
|
/// <see cref="ReadAsync"/> blocks rather than returning 0. Returning 0 means the remote closed the
|
|
/// channel, which would end the session the moment it was opened and make the test assert against a
|
|
/// connection that had already gone. <see cref="Drop"/> and <see cref="Exit"/> are the two ways out.
|
|
/// </remarks>
|
|
internal sealed class FakeSshShellSession : ISshShellSession
|
|
{
|
|
private readonly CancellationTokenSource closed = new();
|
|
|
|
/// <inheritdoc />
|
|
public bool IsOpen { get; private set; } = true;
|
|
|
|
/// <inheritdoc />
|
|
public string? TransportFailure { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Ends this shell the way a rebooted machine does: the read stops, with a transport failure latched.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Latched before the read is released, which is the ordering the real session promises and the whole
|
|
/// reason the distinction is race-free — see <see cref="ISshShellSession.TransportFailure"/>. A fake
|
|
/// that set it afterwards would be testing a guarantee the product does not make.
|
|
/// </remarks>
|
|
internal void Drop(string reason)
|
|
{
|
|
TransportFailure = reason;
|
|
closed.Cancel();
|
|
}
|
|
|
|
/// <summary>Ends this shell the way <c>exit</c> does: the read stops, with nothing wrong underneath.</summary>
|
|
internal void Exit() => closed.Cancel();
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
await Task.Delay(Timeout.InfiniteTimeSpan, closed.Token).ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
// This shell ending, not the pump being asked to stop. Reported as end of stream rather than
|
|
// rethrown, because that is what a real read does for both of the endings above: the channel
|
|
// stops producing bytes, and what happened to the transport is told by the latch, not by an
|
|
// exception coming out of here.
|
|
return 0;
|
|
}
|
|
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
return 0;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken) =>
|
|
ValueTask.CompletedTask;
|
|
|
|
/// <inheritdoc />
|
|
public void Resize(TerminalSize size)
|
|
{
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
IsOpen = false;
|
|
|
|
await closed.CancelAsync().ConfigureAwait(false);
|
|
|
|
closed.Dispose();
|
|
}
|
|
}
|