using DodoSSH.Client.Ssh;
namespace DodoSSH.Client.Terminal.Tests;
/// A shell session that produces output on demand, for exercising the pump.
internal sealed class FakeShellSession : ISshShellSession
{
private readonly List written = [];
private readonly Lock gate = new();
private long remaining;
private byte pattern;
///
/// How many bytes to emit before reporting end of stream. for an
/// endless producer, which is what a runaway remote process looks like — those sessions are ended
/// by disposing the pump rather than by running out of data.
///
internal FakeShellSession(long bytesToProduce = 0) => remaining = bytesToProduce;
///
public bool IsOpen { get; private set; } = true;
/// Reads issued against this session, to detect a pump that kept reading.
public int ReadCount { get; private set; }
/// Bytes the pump wrote toward the remote.
public byte[] Written
{
get
{
lock (gate)
{
return [.. written];
}
}
}
/// The last size the pump forwarded, or null if it forwarded none.
public TerminalSize? LastResize { get; private set; }
/// How many resizes were forwarded, so a dropped one is observable.
public int ResizeCount { get; private set; }
///
///
/// Returns 0 once the configured budget is spent, which is what a remote closing the channel looks
/// like. Not synchronous: a fake that never yields would let the pump's read loop monopolise the
/// thread and hide any ordering problem between reading and flushing.
///
public async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken)
{
ReadCount++;
await Task.Yield();
cancellationToken.ThrowIfCancellationRequested();
if (remaining <= 0)
{
return 0;
}
var count = (int)Math.Min(buffer.Length, remaining);
buffer.Span[..count].Fill(unchecked(pattern++));
remaining -= count;
return count;
}
///
public ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken)
{
lock (gate)
{
written.AddRange(data.ToArray());
}
return ValueTask.CompletedTask;
}
///
public void Resize(TerminalSize size)
{
// Mirrors the real session: an unusable size is dropped rather than forwarded.
if (!size.IsUsable)
{
return;
}
ResizeCount++;
LastResize = size;
}
///
public ValueTask DisposeAsync()
{
IsOpen = false;
remaining = 0;
return ValueTask.CompletedTask;
}
}
/// Records frames, and can acknowledge them to keep credit flowing.
internal sealed class RecordingTransport : ITerminalTransport
{
private readonly List frames = [];
private readonly Lock gate = new();
/// Set to acknowledge every output frame immediately, as a keeping-up renderer would.
internal TerminalSessionPump? AutoAcknowledge { get; set; }
/// Frames sent so far.
internal IReadOnlyList Frames
{
get
{
lock (gate)
{
return [.. frames];
}
}
}
///
public ValueTask SendAsync(ReadOnlyMemory frame, CancellationToken cancellationToken)
{
var copy = frame.ToArray();
lock (gate)
{
frames.Add(copy);
}
if (AutoAcknowledge is { } pump
&& TerminalFrame.TryRead(copy, out var opcode, out _, out var payload)
&& opcode == (byte)TerminalServerOpcode.Output)
{
pump.Acknowledge((uint)payload.Length);
}
return ValueTask.CompletedTask;
}
/// Concatenated payloads of every output frame.
internal byte[] OutputBytes()
{
var output = new List();
foreach (var frame in Frames)
{
if (TerminalFrame.TryRead(frame, out var opcode, out _, out var payload)
&& opcode == (byte)TerminalServerOpcode.Output)
{
output.AddRange(payload);
}
}
return [.. output];
}
/// Counts frames of one opcode.
internal int CountOf(TerminalServerOpcode opcode) =>
Frames.Count(frame =>
TerminalFrame.TryRead(frame, out var actual, out _, out _)
&& actual == (byte)opcode);
}