Public Access
The terminal's memory was bounded by an argument that is not true of the SSH library underneath it, and the whole of CreditWindow's remark was that argument: the pump stops reading when the renderer falls behind, SSH's own receive window closes, the remote sshd blocks on write, and the backpressure reaches the process producing the output. The middle step does not happen. Renci.SshNet's Channels.Channel.OnData calls its window adjustment before it raises DataReceived and re-opens the channel window as soon as it drops below one packet, and ShellStream's own handler copies the bytes into a System.Net.ArrayBuffer without blocking and without a ceiling — a buffer that doubles on demand and is not given back until the stream is disposed. So a reader that stopped reading slowed the remote down by nothing at all. It only chose whose buffer the flood landed in, and it chose the one nothing bounds. Measured against the container this repository already runs sshd in: a reader that reads nothing while `yes` runs leaves ShellStream holding 8.9 MB after one second, 26.8 MB after three and 44.3 MB after five, its capacity stepping 16 → 32 → 64 MB. That is the report of "a massive memory leak on Windows"; it is not a Windows fault, it is simply where long sessions with heavy output live. The fix is to read continuously, which is what keeps SSH.NET's buffer empty, and to put the bound where it can actually be enforced. OutputBacklog is that bound: a byte-capped queue of unsent output, 4 MiB by default, which drops its oldest bytes when full and counts what it discarded. Oldest rather than newest, because the tail of a flood is the prompt somebody is waiting for. The two bounds are now separate and each is enforced where it means something. The backlog bounds what is waiting to be sent. The credit window goes on bounding what has been sent and not yet drawn — the queue inside the WebView that nothing on this side can measure — and is reserved against in the flush loop rather than the read loop. Reading is part of neither, because reading was never what the remote was waiting on. Dropping is announced rather than silent. A terminal quietly showing a different transcript from the one the remote sent is worse than one that says it could not keep up, because the second can be believed. The line goes out under its own opcode, TerminalServerOpcode.OutputDropped, rather than spliced into the output stream: what this application says about the transcript is not part of the transcript, and keeping them apart is also what makes the arithmetic hold — every byte read is either in an Output payload or counted in BytesDropped. It opens with an SGR reset, because the cut lands at an arbitrary offset and whatever escape sequence the remote was in the middle of has been left half-said. Frames are now capped at 64 KiB and taken straight into the frame buffer. The cap is the large-object-heap threshold rather than a guess — a quarter-megabyte array allocated sixty times a second is collected only by a generation 2 pass — and the direct take removes an allocation and a copy of every byte the terminal shows. The second half is the one that made the first half's cost persist. Nothing gave a session back when the remote hung up: TerminalWorkspace kept the entry, and with it an undisposed ISshConnection — an authenticated socket, its cipher state, and a keep-alive putting a packet on the wire every thirty seconds — plus the pump and SSH.NET's read buffer at whatever size the session's busiest moment had grown it to, for as long as the tab stayed open. A tab somebody typed `exit` into an hour ago was indistinguishable from a live one in everything except what it drew. ReleaseAsync disposes both, from AnnounceEndedAsync and after the announcement: the tab is told first, because tearing down an SSH connection in front of that would be latency on the one thing the user is watching for. The dictionary entry stays, because LiveSessionCount, IsSessionLive and the tab's own ENDED and RECONNECT all read it. SendInputAsync therefore asks about liveness rather than presence, since the shell channel is gone the moment the remote is. Two faults introduced on the way here and fixed, both worth naming because neither would have been visible from the outside: The read loop left through a condition on its cancellation token, and RunAsync reads a read loop that returns as the remote having closed the channel. A tab the user closed would have reported itself as a shell that exited on its own. It throws instead. With the reader finished and a renderer that had stopped acknowledging, the flush loop span: output to send, no credit to send it with, and a wait for more output that returned at once because there was some. RunAsync never completed, so the tab was never told and the connection was never released — a session invisible and permanent. The loop now leaves for the drain as soon as the backlog is complete, and RunAsync resets the credit window alongside completing it so a flusher already parked on an acknowledgement that will never come is released. Four tests asserted the old contract and now assert its reverse, which is the point rather than a relaxation — WhenCreditRunsOut_ThePumpStopsReading is now WhenCreditRunsOut_ThePumpKeepsReadingAndTheBacklogHoldsTheLine, and has to prove both halves at once. The throughput harness gained a counting transport, because a consumer that copies every frame into a 32 MB list was the slowest thing in the pipeline and a harness whose consumer cannot keep up measures itself. The fake shell gained a pace, so "a renderer that keeps up loses nothing" can be asked of a producer a network could actually deliver rather than of one running at memory bandwidth. The test that would have caught this in the first place is ARunawayRemote_KeepsBeingReadAndStaysInsideTheBacklog, and it needs the real SSH.NET and the real sshd to say anything: a fake shell hands over bytes when it is asked, and a real channel hands them over when they arrive. Under the old code its BytesRead would have stopped at exactly WindowBytes. 121 terminal tests pass over three consecutive runs with no flakes, 105 against the container, and the app, session, layout and remaining suites are unchanged and green. Manual checks 1.10a and 1.10b cover the two symptoms a person can see: a working set that plateaus under `yes`, and a connection that goes when the shell does rather than when the tab does.
455 lines
18 KiB
C#
455 lines
18 KiB
C#
using System.Net;
|
|
using System.Net.Sockets;
|
|
using System.Text;
|
|
using DodoSSH.Client.Terminal;
|
|
|
|
namespace DodoSSH.Client.Ssh.Tests;
|
|
|
|
/// <summary>
|
|
/// The pump against a real shell, with the transport replaced by a recorder.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Sits between the pump's unit tests, which use a fake shell, and the full end-to-end test, which
|
|
/// adds the loopback socket. Its value is diagnostic: when output does not reach a renderer, this
|
|
/// says whether the pump and SSH.NET are producing anything at all.
|
|
/// </remarks>
|
|
[Collection(SshCollection.Name)]
|
|
public sealed class PumpOverRealSshTests(SshServerFixture fixture)
|
|
{
|
|
/// <summary>Trusts the container's host key, then connects. First contact is refused by design.</summary>
|
|
private async Task<ISshConnection> ConnectTrustedAsync()
|
|
{
|
|
var knownHosts = new InMemoryKnownHostStore();
|
|
var factory = new SshNetConnectionFactory(knownHosts);
|
|
|
|
var request = new SshConnectionRequest(
|
|
fixture.Host,
|
|
fixture.Port,
|
|
SshServerFixture.Username,
|
|
new SshPasswordCredential(SshServerFixture.Password));
|
|
|
|
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
|
await factory.ConnectAsync(request, progress: null, TestContext.Current.CancellationToken));
|
|
|
|
await knownHosts.TrustAsync(unknown.Presentation, TestContext.Current.CancellationToken);
|
|
|
|
return await factory.ConnectAsync(request, progress: null, TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>exit</c> against a real sshd is reported as the remote closing the session, not as a lost
|
|
/// connection.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// ◆ THIS IS THE TEST THE RACE WOULD FAIL. The obvious way to tell an ordinary exit from a dropped
|
|
/// connection is to wait for the read to end and then ask whether the transport is still up — and against
|
|
/// a real server it is wrong most of the time, because this was the only channel on the connection and
|
|
/// sshd hangs up within a millisecond of closing it. By the time anything here could ask, the transport
|
|
/// is gone. A fake shell cannot show that: it has no server behind it deciding to disconnect.
|
|
/// </para>
|
|
/// <para>
|
|
/// What makes the answer settled instead is the failure being latched when it happens rather than
|
|
/// inferred afterwards — see <see cref="ISshShellSession.TransportFailure"/>. Nothing latches one here,
|
|
/// because nothing failed.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AShellThatExits_IsReportedAsTheRemoteClosingItRatherThanAsALostConnection()
|
|
{
|
|
await using var connection = await ConnectTrustedAsync();
|
|
|
|
var shell = await connection.OpenShellAsync(
|
|
new TerminalSize(100, 30, 1000, 750), TestContext.Current.CancellationToken);
|
|
|
|
var transport = new CountingTransport();
|
|
|
|
await using var pump = new TerminalSessionPump(
|
|
1,
|
|
shell,
|
|
transport,
|
|
TimeProvider.System,
|
|
new TerminalPumpOptions { FlushInterval = TimeSpan.FromMilliseconds(20) });
|
|
|
|
transport.Pump = pump;
|
|
|
|
var run = pump.RunAsync(TestContext.Current.CancellationToken);
|
|
|
|
await pump.WriteInputAsync("exit\n"u8.ToArray(), TestContext.Current.CancellationToken);
|
|
|
|
// No disposal and no cancellation: the run has to end because the shell did, which is the whole
|
|
// point. A timeout here would mean the session hangs on `exit`.
|
|
await run.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);
|
|
|
|
pump.Outcome.End.ShouldBe(
|
|
SshSessionEnd.RemoteClosed,
|
|
$"exit is not a dropped connection; got <{pump.Outcome.Description}>");
|
|
}
|
|
|
|
/// <summary>
|
|
/// A transport that goes away under a live shell is reported as a lost connection, and the read does not
|
|
/// hang.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// ◆ THE REBOOT CASE, produced without rebooting anything. The connection is dialled through a plain TCP
|
|
/// relay this test owns and then the relay is torn down, which takes the socket out from under a live
|
|
/// shell exactly as a machine going down does. The container is shared by every test in this collection,
|
|
/// so killing <c>sshd</c> itself was never an option; killing something this test owns is.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>What is not reproduced here is the waiting.</b> A relay that is closed sends a FIN, so the socket
|
|
/// reports itself at once; a machine that has been switched off sends nothing at all, and finding that
|
|
/// out requires writing to it — which is what the keep-alive in <see cref="SshNetConnectionFactory"/>
|
|
/// exists to do, and which no test can hurry along without a clock this code does not own. So this covers
|
|
/// the half that can be checked, and it is the half everything above the socket depends on: a transport
|
|
/// that has failed is <em>reported as one</em> rather than being called an ordinary exit or leaving the
|
|
/// read blocked for ever.
|
|
/// </para>
|
|
/// <para>
|
|
/// A relay rather than the SOCKS forward <c>LoopbackProxyTests</c> uses, which was the first attempt:
|
|
/// disconnecting the carrier of a <c>ForwardedPortDynamic</c> does not close the loopback socket it
|
|
/// accepted, so the inner connection was left holding a socket with nothing behind it and the read did
|
|
/// not return within thirty seconds. That is a property of SSH.NET's forwarder rather than of anything
|
|
/// this repository ships, and it is not what a dying machine looks like.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AShellWhoseTransportDies_IsReportedAsALostConnection()
|
|
{
|
|
using var relay = new TcpRelay(fixture.Host, fixture.Port);
|
|
|
|
await using var connection = await ConnectThroughAsync(relay);
|
|
|
|
var shell = await connection.OpenShellAsync(
|
|
new TerminalSize(100, 30, 1000, 750), TestContext.Current.CancellationToken);
|
|
|
|
var transport = new CountingTransport();
|
|
|
|
await using var pump = new TerminalSessionPump(
|
|
1,
|
|
shell,
|
|
transport,
|
|
TimeProvider.System,
|
|
new TerminalPumpOptions { FlushInterval = TimeSpan.FromMilliseconds(20) });
|
|
|
|
transport.Pump = pump;
|
|
|
|
var run = pump.RunAsync(TestContext.Current.CancellationToken);
|
|
|
|
// The shell is up and talking before anything is broken, so what follows is a live session losing its
|
|
// transport rather than one that never started.
|
|
var deadline = TimeProvider.System.GetUtcNow() + TimeSpan.FromSeconds(20);
|
|
|
|
while (TimeProvider.System.GetUtcNow() < deadline && pump.BytesRead == 0)
|
|
{
|
|
await Task.Delay(50, TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
pump.BytesRead.ShouldBeGreaterThan(0, "the shell should have produced a prompt before the relay goes");
|
|
|
|
// And the ground goes out from under it.
|
|
relay.Kill();
|
|
|
|
// No disposal and no cancellation: the run has to end because the transport did.
|
|
await run.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);
|
|
|
|
pump.Outcome.End.ShouldBe(
|
|
SshSessionEnd.ConnectionLost,
|
|
$"a transport that died is not a shell that exited; got <{pump.Outcome.Description}>");
|
|
|
|
pump.Outcome.Description.ShouldNotBeNullOrWhiteSpace(
|
|
"the strip under the terminal prints this sentence, so an empty one is a blank status line");
|
|
}
|
|
|
|
/// <summary>
|
|
/// A remote flooding a renderer that never draws anything is bounded, and the pump keeps reading.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// ◆ THIS IS THE TEST THE OLD DESIGN WOULD FAIL, AND IT NEEDS A REAL SSH.NET UNDERNEATH IT TO SAY SO.
|
|
/// The pump used to stop reading once a renderer had a credit window's worth of unrendered output, on
|
|
/// the reasoning that SSH's own receive window would then close and block the remote. It does not:
|
|
/// <c>Renci.SshNet.Channels.Channel.OnData</c> adjusts the window as bytes <em>arrive</em>, before it
|
|
/// hands them on, so the remote goes on sending at full speed and <c>ShellStream</c> copies every byte
|
|
/// into a buffer that doubles and is never given back. Against this test, <c>yes</c> would have been
|
|
/// read at <c>BytesRead == WindowBytes</c> exactly and then stopped for ever while the process grew.
|
|
/// A fake shell cannot show that, because a fake hands over bytes when it is asked and a real channel
|
|
/// hands them over when they arrive.
|
|
/// </para>
|
|
/// <para>
|
|
/// So the assertion is in two halves and needs both. Reading keeps going, which is what keeps SSH.NET's
|
|
/// buffer drained; and what this side holds stays inside the backlog, which is what keeps the drained
|
|
/// bytes from simply piling up here instead. Either alone is satisfied by a design that leaks.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ARunawayRemote_KeepsBeingReadAndStaysInsideTheBacklog()
|
|
{
|
|
const int Window = 64 * 1024;
|
|
const int Backlog = 512 * 1024;
|
|
|
|
await using var connection = await ConnectTrustedAsync();
|
|
|
|
var shell = await connection.OpenShellAsync(
|
|
new TerminalSize(100, 30, 1000, 750), TestContext.Current.CancellationToken);
|
|
|
|
// Deliberately never acknowledges: this is a renderer that has stopped drawing, which is the whole
|
|
// condition under examination. CountingTransport only acknowledges when it is given a pump.
|
|
var transport = new CountingTransport();
|
|
|
|
await using var pump = new TerminalSessionPump(
|
|
1,
|
|
shell,
|
|
transport,
|
|
TimeProvider.System,
|
|
new TerminalPumpOptions
|
|
{
|
|
FlushInterval = TimeSpan.FromMilliseconds(20),
|
|
WindowBytes = Window,
|
|
BacklogBytes = Backlog,
|
|
});
|
|
|
|
var run = pump.RunAsync(TestContext.Current.CancellationToken);
|
|
|
|
await pump.WriteInputAsync("yes dodossh\n"u8.ToArray(), TestContext.Current.CancellationToken);
|
|
|
|
// Long enough that a pump which had stopped at the window would plainly have stopped, and that one
|
|
// which had not would have read many times the backlog's capacity.
|
|
var deadline = TimeProvider.System.GetUtcNow() + TimeSpan.FromSeconds(10);
|
|
|
|
while (TimeProvider.System.GetUtcNow() < deadline && pump.BytesRead < Backlog * 4L)
|
|
{
|
|
await Task.Delay(100, TestContext.Current.CancellationToken);
|
|
|
|
pump.BytesPending.ShouldBeLessThanOrEqualTo(
|
|
Backlog,
|
|
"output is accumulating on this side of the socket, which is the growth the backlog bounds");
|
|
}
|
|
|
|
pump.BytesRead.ShouldBeGreaterThan(
|
|
Backlog * 4L,
|
|
"the pump stopped reading, so the flood is piling up inside SSH.NET where nothing bounds it");
|
|
|
|
pump.BytesDropped.ShouldBeGreaterThan(
|
|
0,
|
|
"nothing was dropped, so something between here and the screen is still holding all of it");
|
|
|
|
await pump.DisposeAsync();
|
|
|
|
await Should.NotThrowAsync(() => run.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken));
|
|
}
|
|
|
|
/// <summary>Trusts the relay's identity, then connects through it.</summary>
|
|
/// <remarks>
|
|
/// Pinned under the relay's own address, which is the honest thing here: what is under examination is a
|
|
/// transport dying, and this store has one entry in it either way.
|
|
/// </remarks>
|
|
private static async Task<ISshConnection> ConnectThroughAsync(TcpRelay relay)
|
|
{
|
|
var knownHosts = new InMemoryKnownHostStore();
|
|
var factory = new SshNetConnectionFactory(knownHosts);
|
|
|
|
var request = new SshConnectionRequest(
|
|
"127.0.0.1",
|
|
relay.Port,
|
|
SshServerFixture.Username,
|
|
new SshPasswordCredential(SshServerFixture.Password));
|
|
|
|
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
|
await factory.ConnectAsync(request, progress: null, TestContext.Current.CancellationToken));
|
|
|
|
await knownHosts.TrustAsync(unknown.Presentation, TestContext.Current.CancellationToken);
|
|
|
|
return await factory.ConnectAsync(request, progress: null, TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
/// <summary>A loopback TCP forwarder that can be cut, standing in for a machine that goes away.</summary>
|
|
/// <remarks>
|
|
/// Deliberately dumb: it accepts one connection, opens one to the far end and copies bytes both ways
|
|
/// until something stops. SSH is opaque to it, which is the point — what is being simulated is the wire
|
|
/// disappearing, and a relay that understood the protocol could not simulate that without also deciding
|
|
/// how to say goodbye.
|
|
/// </remarks>
|
|
private sealed class TcpRelay : IDisposable
|
|
{
|
|
private readonly TcpListener listener;
|
|
private readonly CancellationTokenSource lifetime = new();
|
|
private readonly List<TcpClient> sockets = [];
|
|
private readonly Lock gate = new();
|
|
|
|
internal TcpRelay(string host, int port)
|
|
{
|
|
listener = new TcpListener(IPAddress.Loopback, 0);
|
|
listener.Start();
|
|
|
|
Port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
|
|
|
_ = AcceptAsync(host, port);
|
|
}
|
|
|
|
/// <summary>The loopback port to dial instead of the far end's.</summary>
|
|
internal int Port { get; }
|
|
|
|
/// <summary>
|
|
/// Drops every socket, in both directions, without warning either end first.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Disposing the sockets is what closes them, and closing is what puts a FIN on the wire. Nothing
|
|
/// here speaks to the SSH session running over it, which is the whole idea: this is the network
|
|
/// going away, not a disconnect being negotiated.
|
|
/// </remarks>
|
|
internal void Kill()
|
|
{
|
|
lifetime.Cancel();
|
|
listener.Stop();
|
|
|
|
lock (gate)
|
|
{
|
|
foreach (var socket in sockets)
|
|
{
|
|
socket.Dispose();
|
|
}
|
|
|
|
sockets.Clear();
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
Kill();
|
|
lifetime.Dispose();
|
|
}
|
|
|
|
private async Task AcceptAsync(string host, int port)
|
|
{
|
|
try
|
|
{
|
|
while (!lifetime.Token.IsCancellationRequested)
|
|
{
|
|
var inbound = await listener.AcceptTcpClientAsync(lifetime.Token).ConfigureAwait(false);
|
|
var outbound = new TcpClient();
|
|
|
|
await outbound.ConnectAsync(host, port, lifetime.Token).ConfigureAwait(false);
|
|
|
|
lock (gate)
|
|
{
|
|
sockets.Add(inbound);
|
|
sockets.Add(outbound);
|
|
}
|
|
|
|
_ = CopyAsync(inbound, outbound);
|
|
_ = CopyAsync(outbound, inbound);
|
|
}
|
|
}
|
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
|
{
|
|
// Killed, or the listener stopped. There is no caller on this thread to hand anything to.
|
|
}
|
|
}
|
|
|
|
private async Task CopyAsync(TcpClient from, TcpClient to)
|
|
{
|
|
try
|
|
{
|
|
await from.GetStream().CopyToAsync(to.GetStream(), lifetime.Token).ConfigureAwait(false);
|
|
}
|
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
|
{
|
|
// Either end going is the ordinary way this ends, and is what the test is arranging.
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ThePump_ForwardsRealShellOutput()
|
|
{
|
|
await using var connection = await ConnectTrustedAsync();
|
|
|
|
var shell = await connection.OpenShellAsync(
|
|
new TerminalSize(100, 30, 1000, 750), TestContext.Current.CancellationToken);
|
|
|
|
var transport = new CountingTransport();
|
|
|
|
await using var pump = new TerminalSessionPump(
|
|
1,
|
|
shell,
|
|
transport,
|
|
TimeProvider.System,
|
|
new TerminalPumpOptions { FlushInterval = TimeSpan.FromMilliseconds(20) });
|
|
|
|
transport.Pump = pump;
|
|
|
|
var run = pump.RunAsync(TestContext.Current.CancellationToken);
|
|
|
|
// Marker split so the PTY's echo of the command line does not satisfy the match.
|
|
await pump.WriteInputAsync(
|
|
Encoding.UTF8.GetBytes("echo \"DODO\"\"SSH-OK\"\n"),
|
|
TestContext.Current.CancellationToken);
|
|
|
|
var deadline = TimeProvider.System.GetUtcNow() + TimeSpan.FromSeconds(20);
|
|
|
|
while (TimeProvider.System.GetUtcNow() < deadline
|
|
&& !transport.Text.Contains("DODOSSH-OK", StringComparison.Ordinal))
|
|
{
|
|
await Task.Delay(50, TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
transport.Text.Contains("DODOSSH-OK", StringComparison.Ordinal).ShouldBeTrue(
|
|
$"BytesRead={pump.BytesRead}, FramesSent={pump.FramesSent}, "
|
|
+ $"OutputFrames={transport.OutputFrames}, Text=<{transport.Text}>");
|
|
|
|
await pump.DisposeAsync();
|
|
|
|
try
|
|
{
|
|
await run;
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Expected: disposing the pump cancels its run.
|
|
}
|
|
}
|
|
|
|
/// <summary>Accumulates output and acknowledges it, as a keeping-up renderer would.</summary>
|
|
private sealed class CountingTransport : ITerminalTransport
|
|
{
|
|
private readonly StringBuilder text = new();
|
|
private readonly Lock gate = new();
|
|
|
|
internal TerminalSessionPump? Pump { get; set; }
|
|
|
|
internal int OutputFrames { get; private set; }
|
|
|
|
internal string Text
|
|
{
|
|
get
|
|
{
|
|
lock (gate)
|
|
{
|
|
return text.ToString();
|
|
}
|
|
}
|
|
}
|
|
|
|
public ValueTask SendAsync(ReadOnlyMemory<byte> frame, CancellationToken cancellationToken)
|
|
{
|
|
if (!TerminalFrame.TryRead(frame.Span, out var opcode, out _, out var payload)
|
|
|| opcode != (byte)TerminalServerOpcode.Output)
|
|
{
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
lock (gate)
|
|
{
|
|
OutputFrames++;
|
|
text.Append(Encoding.UTF8.GetString(payload));
|
|
}
|
|
|
|
Pump?.Acknowledge((uint)payload.Length);
|
|
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
}
|
|
}
|