Public Access
Replay the live sessions to a renderer that just attached
Each live session gets its credit window reset — the unacknowledged bytes died with the old page, and their acknowledgement is never coming — and its SessionOpened frame again, flagged as a replay so the page can tell a reattach from a genuinely new session. A session whose shell already ended gets nothing: its scrollback lived only in the page that is gone, and a frame implying otherwise would lie. RendererReattached is the seam for what the workspace has no business owning: the font size and the selected tab live in the shell, which re-pushes them from its own subscription.
This commit is contained in:
@@ -8,6 +8,8 @@ internal sealed class FakeShellSession : ISshShellSession
|
||||
private readonly List<byte> written = [];
|
||||
private readonly Lock gate = new();
|
||||
|
||||
private readonly bool blockReads;
|
||||
|
||||
private long remaining;
|
||||
private byte pattern;
|
||||
|
||||
@@ -16,7 +18,19 @@ internal sealed class FakeShellSession : ISshShellSession
|
||||
/// 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.
|
||||
/// </param>
|
||||
internal FakeShellSession(long bytesToProduce = 0) => remaining = bytesToProduce;
|
||||
/// <param name="blockReads">
|
||||
/// True for a shell that is open and live but has nothing to say — an idle prompt, rather than either
|
||||
/// end of the "produces bytes" and "hit end of stream" spectrum <paramref name="bytesToProduce"/>
|
||||
/// covers. <see cref="ReadAsync"/> then blocks until cancelled, which is what a real idle SSH channel's
|
||||
/// read does. Exists for tests that need a session whose <c>Run</c> stays live without a background
|
||||
/// read loop racing the test for control of the pump's credit window — see the reattach tests in
|
||||
/// <c>TerminalWorkspaceTests</c>.
|
||||
/// </param>
|
||||
internal FakeShellSession(long bytesToProduce = 0, bool blockReads = false)
|
||||
{
|
||||
remaining = bytesToProduce;
|
||||
this.blockReads = blockReads;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsOpen { get; private set; } = true;
|
||||
@@ -52,6 +66,13 @@ internal sealed class FakeShellSession : ISshShellSession
|
||||
{
|
||||
ReadCount++;
|
||||
|
||||
if (blockReads)
|
||||
{
|
||||
// Never completes on its own. The only way out is the same way a real blocked read ends: the
|
||||
// token being cancelled, which is what disposing the pump does.
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await Task.Yield();
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
@@ -109,7 +130,8 @@ internal sealed class FakeShellSession : ISshShellSession
|
||||
/// workspace is the layer that decides when a session is over, and that decision is what needs a
|
||||
/// connection whose shell can be made to end on cue.
|
||||
/// </remarks>
|
||||
internal sealed class FakeConnectionFactory(long bytesPerShell = long.MaxValue) : ISshConnectionFactory
|
||||
internal sealed class FakeConnectionFactory(long bytesPerShell = long.MaxValue, bool blockShellReads = false)
|
||||
: ISshConnectionFactory
|
||||
{
|
||||
/// <summary>Connections handed out, in order.</summary>
|
||||
internal List<FakeConnection> Connections { get; } = [];
|
||||
@@ -119,7 +141,7 @@ internal sealed class FakeConnectionFactory(long bytesPerShell = long.MaxValue)
|
||||
SshConnectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = new FakeConnection(request, bytesPerShell);
|
||||
var connection = new FakeConnection(request, bytesPerShell, blockShellReads);
|
||||
Connections.Add(connection);
|
||||
|
||||
return Task.FromResult<ISshConnection>(connection);
|
||||
@@ -127,7 +149,8 @@ internal sealed class FakeConnectionFactory(long bytesPerShell = long.MaxValue)
|
||||
}
|
||||
|
||||
/// <summary>A connection that opens fake shells and records its own disposal.</summary>
|
||||
internal sealed class FakeConnection(SshConnectionRequest request, long bytesPerShell) : ISshConnection
|
||||
internal sealed class FakeConnection(SshConnectionRequest request, long bytesPerShell, bool blockShellReads = false)
|
||||
: ISshConnection
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public bool IsConnected { get; private set; } = true;
|
||||
@@ -148,7 +171,7 @@ internal sealed class FakeConnection(SshConnectionRequest request, long bytesPer
|
||||
/// <inheritdoc />
|
||||
public Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken)
|
||||
{
|
||||
Shell = new FakeShellSession(bytesPerShell);
|
||||
Shell = new FakeShellSession(bytesPerShell, blockShellReads);
|
||||
|
||||
return Task.FromResult<ISshShellSession>(Shell);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
using System.Globalization;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using DodoSSH.Client.Ssh;
|
||||
|
||||
namespace DodoSSH.Client.Terminal.Tests;
|
||||
@@ -291,6 +294,85 @@ public sealed class TerminalWorkspaceTests
|
||||
connections.Connections.ShouldAllBe(connection => connection.IsDisposed);
|
||||
}
|
||||
|
||||
// ---- Reattach ----
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The scenario the whole fix exists for: a page that lost its socket — killed WebView renderer, or
|
||||
/// simply a reload — reattaches, and the session that was already running has to come back rather than
|
||||
/// sit there forever with its output going nowhere.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The session's shell blocks on every read rather than producing output, which is what an idle prompt
|
||||
/// looks like and — for this test — is what keeps its <c>Run</c> live without a background read loop
|
||||
/// competing with this test over the credit window's exact value.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Neither assertion below polls, deliberately. The "before" one does not need to: reserving credit is
|
||||
/// a synchronous call, so it is true the instant it returns. The "after" one does not need to either,
|
||||
/// for a subtler reason — <see cref="TerminalWorkspace.ReplayAfterAttachAsync"/> calls
|
||||
/// <c>Credits.Reset()</c> and only then awaits sending the replay frame for that same session, with no
|
||||
/// suspension between the two, so by the time this test has received that frame the reset has
|
||||
/// necessarily already happened. A poll here would only have hidden a real ordering bug behind a
|
||||
/// generous timeout instead of catching it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ANewRenderer_ReplaysTheLiveSessionAndResetsItsCredits()
|
||||
{
|
||||
var connections = new FakeConnectionFactory(blockShellReads: true);
|
||||
|
||||
await using var workspace = CreateWorkspace(connections);
|
||||
workspace.Start();
|
||||
|
||||
using var first = await ConnectRendererAsync(workspace);
|
||||
|
||||
var sessionId = await workspace.OpenSessionAsync(
|
||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||
|
||||
// The session's own opening frame, sent as soon as the pump starts running. Not a replay, and not
|
||||
// what this test is about — read and discarded so it cannot be confused for one below.
|
||||
await ReceiveFrameAsync(first);
|
||||
|
||||
var credits = workspace.CreditsFor(sessionId).ShouldNotBeNull();
|
||||
credits.TryReserve(4096);
|
||||
credits.Outstanding.ShouldBeGreaterThanOrEqualTo(
|
||||
4096, "the pump's own read loop may have reserved a buffer's worth on top of this");
|
||||
|
||||
using var second = await ConnectRendererAsync(workspace);
|
||||
|
||||
var replay = await ReceiveFrameAsync(second);
|
||||
replay.Opcode.ShouldBe((byte)TerminalServerOpcode.SessionOpened);
|
||||
replay.SessionId.ShouldBe(sessionId);
|
||||
replay.Payload.ShouldBe(new byte[] { 1 }, "a replay is flagged so the page can tell it apart from a fresh open");
|
||||
|
||||
credits.Outstanding.ShouldBe(0, "the replay frame above cannot have been sent before the reset that precedes it");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The other half of a reattach: the workspace has replayed what it owns, and this is the seam the
|
||||
/// shell uses to replay what it owns instead — the font size and the selected tab, neither of which a
|
||||
/// terminal session knows anything about. <c>MainWindowViewModel</c>'s subscription is what actually
|
||||
/// does that; this only asserts that the workspace hands it the chance to.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ANewRenderer_RaisesRendererReattached()
|
||||
{
|
||||
var connections = new FakeConnectionFactory();
|
||||
|
||||
await using var workspace = CreateWorkspace(connections);
|
||||
workspace.Start();
|
||||
|
||||
using var first = await ConnectRendererAsync(workspace);
|
||||
|
||||
var reattachedCount = 0;
|
||||
workspace.RendererReattached += (_, _) => Interlocked.Increment(ref reattachedCount);
|
||||
|
||||
using var second = await ConnectRendererAsync(workspace);
|
||||
|
||||
await WaitUntilAsync(() => Volatile.Read(ref reattachedCount) > 0);
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
/// <remarks>
|
||||
@@ -362,12 +444,76 @@ public sealed class TerminalWorkspaceTests
|
||||
private static InMemoryTerminalAssetProvider StubAssets() =>
|
||||
new(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)
|
||||
{
|
||||
[TerminalDataPlane.PagePath] = new("text/html; charset=utf-8", "<!doctype html>"u8.ToArray()),
|
||||
// The placeholders, not a token and URL already filled in — the reattach tests below have to
|
||||
// connect a real renderer, and doing that by reading them back out of the served page is what
|
||||
// proves the workspace serves a page a real renderer could actually attach with, rather than
|
||||
// one that merely looks servable.
|
||||
[TerminalDataPlane.PagePath] = new(
|
||||
"text/html; charset=utf-8",
|
||||
Encoding.UTF8.GetBytes(
|
||||
$"<html><body data-token=\"{TerminalDataPlane.TokenPlaceholder}\" "
|
||||
+ $"data-socket=\"{TerminalDataPlane.SocketUrlPlaceholder}\"></body></html>")),
|
||||
});
|
||||
|
||||
private static SshConnectionRequest Request() =>
|
||||
new("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant"));
|
||||
|
||||
/// <remarks>
|
||||
/// Attaches the way the real page does: by fetching the served page, reading the token and socket URL
|
||||
/// back out of it, and presenting them on the upgrade — rather than reaching into the workspace for a
|
||||
/// token it does not expose. A shortcut here would prove only that a socket can be opened, not that the
|
||||
/// workspace serves a page a renderer could actually attach with.
|
||||
/// </remarks>
|
||||
private static async Task<ClientWebSocket> ConnectRendererAsync(TerminalWorkspace workspace)
|
||||
{
|
||||
using var http = new HttpClient();
|
||||
var page = await http.GetStringAsync(workspace.PageUrl, TestContext.Current.CancellationToken);
|
||||
|
||||
var token = ExtractAttribute(page, "data-token");
|
||||
var socketUrl = ExtractAttribute(page, "data-socket");
|
||||
|
||||
var client = new ClientWebSocket();
|
||||
client.Options.AddSubProtocol(TerminalDataPlane.SubProtocol);
|
||||
client.Options.AddSubProtocol($"token.{token}");
|
||||
client.Options.SetRequestHeader(
|
||||
"Origin",
|
||||
string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{workspace.PageUrl.Port}"));
|
||||
|
||||
try
|
||||
{
|
||||
await client.ConnectAsync(new Uri(socketUrl), TestContext.Current.CancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
client.Dispose();
|
||||
throw;
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
private static string ExtractAttribute(string html, string name)
|
||||
{
|
||||
var marker = $"{name}=\"";
|
||||
var start = html.IndexOf(marker, StringComparison.Ordinal) + marker.Length;
|
||||
var end = html.IndexOf('"', start);
|
||||
|
||||
return html[start..end];
|
||||
}
|
||||
|
||||
private static async Task<(byte Opcode, uint SessionId, byte[] Payload)> ReceiveFrameAsync(
|
||||
ClientWebSocket socket)
|
||||
{
|
||||
var buffer = new byte[64 * 1024];
|
||||
|
||||
var result = await socket.ReceiveAsync(buffer.AsMemory(), TestContext.Current.CancellationToken);
|
||||
|
||||
TerminalFrame.TryRead(buffer.AsSpan(0, result.Count), out var opcode, out var sessionId, out var payload)
|
||||
.ShouldBeTrue();
|
||||
|
||||
return (opcode, sessionId, payload.ToArray());
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Polled rather than awaited on a task, because the point is what an observer of the property
|
||||
/// sees: the pump ends on a thread of its own, and the count has to catch up without anyone
|
||||
|
||||
Reference in New Issue
Block a user