Public Access
VaultViewModel.ConnectAsync awaited TerminalWorkspace.WaitForRendererAsync
with no timeout and no token, and RunAsync clears IsBusy only after the
work returns. Whether the renderer attaches at all depends on a runtime
this application does not install: with a missing or policy-blocked
Evergreen runtime, or an AppContainer that cannot reach loopback, the
socket never arrives — so Connect never returned, the window stayed
disabled on "Connecting…" for the rest of the session, and nothing on
screen said why. Left out of 0500e43 to keep that change focused, and
recorded in docs/platform-flags.md as worth fixing on its own merits.
The gate itself is unchanged and has to stay: TerminalDataPlane.SendAsync
drops frames when no renderer is attached rather than queueing them, so a
session opened before the renderer arrives loses its SessionOpened frame
and then streams output at a terminal that was never created. Only the
wait changed — RendererAttached.WaitAsync(timeout, cancellationToken),
with the command's own token threaded through.
Fifteen seconds, on TerminalWorkspaceOptions.RendererTimeout. Attaching is
normally near-instant, since WebView2 starts with the window and the page
has usually attached while the passphrase was still being typed, but a
first run on a cold profile creates a user-data directory and starts a
process tree of some thirty-five processes first, which on a loaded
machine is seconds rather than milliseconds. A renderer that will never
attach will not attach however long the wait is, so being generous costs
only how long a broken runtime takes to say so, while being tight costs
telling someone their runtime is broken when it was merely slow.
Injectable because both new tests would otherwise sit out that budget.
The timeout is caught in VaultViewModel rather than left to RunAsync's
generic handler, because TimeoutException.Message is "The operation has
timed out" — which sends someone looking at their network or their host.
The status now names the WebView2 runtime and says to install it.
TerminalWorkspaceTests covers the half that was missing: the wait gives up
(329 ms against a 250 ms budget) and obeys its token (2 ms against a
five-minute one). Before the bound, the first of those would have hung
rather than failed. ShellFlowTests never starts its workspace, which from
the view model's side is indistinguishable from a WebView2 that failed to
initialise, so it asserts that the status names WebView2 and that IsBusy
is cleared; changing the catch to another exception type makes it fail
with "The operation has timed out.", so neither assertion is vacuous. The
success path is untouched and still covered end to end by
TerminalEndToEndTests against a real sshd container, which now passes the
test's cancellation token.
One byproduct: the doc comment on WaitForRendererAsync carried two
double-encoded em dashes, fixed now that the block is rewritten.
253 lines
9.5 KiB
C#
253 lines
9.5 KiB
C#
using System.Globalization;
|
|
using System.Net.WebSockets;
|
|
using System.Text;
|
|
using DodoSSH.Client.Terminal;
|
|
|
|
namespace DodoSSH.Client.Ssh.Tests;
|
|
|
|
/// <summary>
|
|
/// A real SSH session, through the real data plane, to a stand-in renderer.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Everything the desktop client does when a user opens a terminal, minus the pixels: a real
|
|
/// <c>sshd</c> in a container, a real pseudo-terminal, the real loopback WebSocket with its token and
|
|
/// origin checks, and a <see cref="ClientWebSocket"/> standing in for the page. If a shell prompt
|
|
/// arrives here and typed input round-trips, the only untested link left is xterm drawing bytes it was
|
|
/// handed.
|
|
/// </para>
|
|
/// <para>
|
|
/// Worth having because the alternative is driving a GUI. The WebView's own participation is
|
|
/// verifiable separately — it opens a TCP connection to this same port — but that says nothing about
|
|
/// whether an SSH session's output reaches it.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Collection(SshCollection.Name)]
|
|
public sealed class TerminalEndToEndTests(SshServerFixture fixture)
|
|
{
|
|
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30);
|
|
|
|
/// <remarks>
|
|
/// The real page is an Avalonia resource in the app project. This test stands in for the renderer
|
|
/// itself, so a placeholder-bearing stub is all the transport needs.
|
|
/// </remarks>
|
|
private static InMemoryTerminalAssetProvider StubAssets() =>
|
|
new(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)
|
|
{
|
|
[TerminalDataPlane.PagePath] = new(
|
|
"text/html; charset=utf-8",
|
|
Encoding.UTF8.GetBytes(
|
|
$"<html data-token=\"{TerminalDataPlane.TokenPlaceholder}\" "
|
|
+ $"data-socket=\"{TerminalDataPlane.SocketUrlPlaceholder}\"></html>")),
|
|
});
|
|
|
|
[Fact]
|
|
public async Task AShellSessionReachesTheRenderer_AndInputReachesTheRemote()
|
|
{
|
|
var knownHosts = new InMemoryKnownHostStore();
|
|
|
|
await using var workspace = new TerminalWorkspace(
|
|
StubAssets(),
|
|
new SshNetConnectionFactory(knownHosts),
|
|
TimeProvider.System);
|
|
|
|
workspace.Start();
|
|
|
|
// The token comes from the served page, exactly as the real renderer obtains it.
|
|
var token = await ReadTokenAsync(workspace.PageUrl);
|
|
|
|
using var renderer = await AttachAsync(workspace.PageUrl, token);
|
|
await workspace.WaitForRendererAsync(TestContext.Current.CancellationToken);
|
|
|
|
var sessionId = await OpenTrustedSessionAsync(workspace, knownHosts);
|
|
|
|
// SessionOpened tells the renderer to create a terminal before any output arrives for it.
|
|
var opened = await ReceiveAsync(renderer);
|
|
opened.Opcode.ShouldBe((byte)TerminalServerOpcode.SessionOpened);
|
|
opened.SessionId.ShouldBe(sessionId);
|
|
|
|
// The login banner and prompt arrive unprompted, acknowledged as the page does from
|
|
// term.write's callback.
|
|
var banner = await ReadOutputUntilAsync(renderer, sessionId, "$", acknowledge: true);
|
|
banner.ShouldContain("OpenSSH");
|
|
|
|
// Marker split so the PTY's echo of the command line does not satisfy the match.
|
|
await SendAsync(
|
|
renderer,
|
|
sessionId,
|
|
(byte)TerminalClientOpcode.Input,
|
|
Encoding.UTF8.GetBytes("echo \"DODO\"\"SSH-OK\"; stty size\n"));
|
|
|
|
var output = await ReadOutputUntilAsync(renderer, sessionId, "DODOSSH-OK", acknowledge: true);
|
|
|
|
output.ShouldContain("DODOSSH-OK");
|
|
|
|
// The size requested when the session opened is the size the remote sees, which means the
|
|
// pty-req carried it rather than the terminal silently defaulting to 80x24.
|
|
output.Replace('\r', '\n').ShouldContain("30 100");
|
|
|
|
await workspace.CloseSessionAsync(sessionId);
|
|
}
|
|
|
|
// ---- Helpers ----
|
|
|
|
/// <summary>
|
|
/// Trusts the container's host key, then opens a session.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The refused first attempt is part of the assertion, not setup noise: a host with no pinned key
|
|
/// must not connect, and the fingerprint the user would be shown has to be in the exception.
|
|
/// </remarks>
|
|
private async Task<uint> OpenTrustedSessionAsync(
|
|
TerminalWorkspace workspace,
|
|
InMemoryKnownHostStore knownHosts)
|
|
{
|
|
var request = new SshConnectionRequest(
|
|
fixture.Host,
|
|
fixture.Port,
|
|
SshServerFixture.Username,
|
|
new SshPasswordCredential(SshServerFixture.Password));
|
|
|
|
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
|
await workspace.OpenSessionAsync(
|
|
request, TerminalSize.Default, TestContext.Current.CancellationToken));
|
|
|
|
unknown.Presentation.Fingerprint.ShouldStartWith(SshHostKeyFingerprint.Prefix);
|
|
|
|
await knownHosts.TrustAsync(unknown.Presentation, TestContext.Current.CancellationToken);
|
|
|
|
return await workspace.OpenSessionAsync(
|
|
request,
|
|
new TerminalSize(100, 30, 1000, 750),
|
|
TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
private static async Task<string> ReadTokenAsync(Uri pageUrl)
|
|
{
|
|
using var client = new HttpClient();
|
|
var page = await client.GetStringAsync(pageUrl, TestContext.Current.CancellationToken);
|
|
|
|
const string Marker = "data-token=\"";
|
|
var start = page.IndexOf(Marker, StringComparison.Ordinal) + Marker.Length;
|
|
var end = page.IndexOf('"', start);
|
|
|
|
return page[start..end];
|
|
}
|
|
|
|
private static async Task<ClientWebSocket> AttachAsync(Uri pageUrl, string token)
|
|
{
|
|
var socket = new ClientWebSocket();
|
|
|
|
socket.Options.AddSubProtocol(TerminalDataPlane.SubProtocol);
|
|
socket.Options.AddSubProtocol($"token.{token}");
|
|
socket.Options.SetRequestHeader(
|
|
"Origin",
|
|
string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{pageUrl.Port}"));
|
|
|
|
try
|
|
{
|
|
await socket.ConnectAsync(
|
|
new Uri($"ws://127.0.0.1:{pageUrl.Port}{TerminalDataPlane.SocketPath}"),
|
|
TestContext.Current.CancellationToken);
|
|
}
|
|
catch
|
|
{
|
|
socket.Dispose();
|
|
throw;
|
|
}
|
|
|
|
return socket;
|
|
}
|
|
|
|
private static Task SendAsync(
|
|
ClientWebSocket socket,
|
|
uint sessionId,
|
|
byte opcode,
|
|
byte[] payload) =>
|
|
socket.SendAsync(
|
|
TerminalFrame.Create(opcode, sessionId, payload),
|
|
WebSocketMessageType.Binary,
|
|
endOfMessage: true,
|
|
TestContext.Current.CancellationToken);
|
|
|
|
/// <remarks>
|
|
/// Bounded by its own timeout rather than relying on a caller's deadline. A blocking receive is
|
|
/// where a missing frame turns into a hung test run instead of a failure with a message, and a hang
|
|
/// tells you nothing about which frame never came.
|
|
/// </remarks>
|
|
private static async Task<(byte Opcode, uint SessionId, byte[] Payload)> ReceiveAsync(
|
|
ClientWebSocket socket)
|
|
{
|
|
var buffer = new byte[256 * 1024];
|
|
|
|
using var deadline = new CancellationTokenSource(Timeout);
|
|
using var linked = CancellationTokenSource.CreateLinkedTokenSource(
|
|
deadline.Token,
|
|
TestContext.Current.CancellationToken);
|
|
|
|
WebSocketReceiveResult result;
|
|
try
|
|
{
|
|
result = await socket.ReceiveAsync(buffer, linked.Token);
|
|
}
|
|
catch (OperationCanceledException) when (deadline.IsCancellationRequested)
|
|
{
|
|
throw new TimeoutException($"No terminal frame arrived within {Timeout}.");
|
|
}
|
|
|
|
TerminalFrame.TryRead(
|
|
buffer.AsSpan(0, result.Count), out var opcode, out var sessionId, out var payload)
|
|
.ShouldBeTrue();
|
|
|
|
return (opcode, sessionId, payload.ToArray());
|
|
}
|
|
|
|
/// <summary>Reads output frames until the text appears, acknowledging each as the page does.</summary>
|
|
private static async Task<string> ReadOutputUntilAsync(
|
|
ClientWebSocket socket,
|
|
uint sessionId,
|
|
string expected,
|
|
bool acknowledge)
|
|
{
|
|
var accumulated = new StringBuilder();
|
|
var deadline = TimeProvider.System.GetUtcNow() + Timeout;
|
|
|
|
while (TimeProvider.System.GetUtcNow() < deadline)
|
|
{
|
|
var frame = await ReceiveAsync(socket);
|
|
|
|
if (frame.Opcode == (byte)TerminalServerOpcode.SessionClosed)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"The session closed before '{expected}' arrived: "
|
|
+ $"{Encoding.UTF8.GetString(frame.Payload)}\nSeen so far:\n{accumulated}");
|
|
}
|
|
|
|
if (frame.Opcode != (byte)TerminalServerOpcode.Output)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (acknowledge)
|
|
{
|
|
// Returning credit is what keeps the pump reading. Without it the session stalls at
|
|
// the window size and this loop would time out on a working implementation.
|
|
await SendAsync(
|
|
socket,
|
|
sessionId,
|
|
(byte)TerminalClientOpcode.Acknowledge,
|
|
TerminalFrame.CreateAcknowledgementPayload((uint)frame.Payload.Length));
|
|
}
|
|
|
|
accumulated.Append(Encoding.UTF8.GetString(frame.Payload));
|
|
|
|
if (accumulated.ToString().Contains(expected, StringComparison.Ordinal))
|
|
{
|
|
return accumulated.ToString();
|
|
}
|
|
}
|
|
|
|
throw new TimeoutException($"'{expected}' did not arrive within {Timeout}.\n{accumulated}");
|
|
}
|
|
}
|