Merge branch 'claude/distracted-ritchie-53fc70'

Bounds the renderer wait, so a WebView2 that never initialises reports itself
instead of hanging Connect with the busy flag stuck.

Conflict resolution, all of it in the App test suite, which main had changed
under the branch when sleepy-chebyshev landed:

- The workspace fixture keeps main's fake SSH factory and its FakeRenderer-aware
  page, and takes the branch's RendererTimeout on top. One second rather than
  the branch's 250 ms, because the timeout now also bounds FakeRenderer's own
  wait for the attach it just made.
- FakeRenderer arrived on main after the branch was cut and still called the
  no-argument WaitForRendererAsync. Both sides merged cleanly and left the build
  broken; it now passes its own token.
- ConnectingWithNoRenderer's remark claimed the suite never starts the workspace
  and never attaches a renderer. Both are false here, so it now says what is
  true of the test: it is the one connect test that attaches no renderer.
This commit is contained in:
2026-07-29 15:40:25 +02:00
7 changed files with 175 additions and 14 deletions
@@ -604,13 +604,14 @@ internal sealed partial class VaultViewModel(
/// <remarks>
/// The renderer has to be attached before a session opens: the transport drops frames when nothing is
/// connected, so a session opened earlier would lose its <c>SessionOpened</c> frame and then stream
/// output at a terminal that was never created.
/// output at a terminal that was never created. That wait is bounded and takes this command's token, so
/// a renderer that never arrives ends as a message rather than as a window stuck on "Connecting…".
/// </remarks>
private async Task OpenSessionAsync(HostRowViewModel row, CancellationToken cancellationToken)
{
try
{
await workspace.WaitForRendererAsync().ConfigureAwait(true);
await workspace.WaitForRendererAsync(cancellationToken).ConfigureAwait(true);
var request = new SshConnectionRequest(
row.Host.Hostname,
@@ -630,6 +631,14 @@ internal sealed partial class VaultViewModel(
// UI instead of the remote shell.
SessionOpened?.Invoke(this, EventArgs.Empty);
}
catch (TimeoutException)
{
// The renderer never attached, so nothing was connected. Reported here rather than left to
// RunAsync's generic handler because TimeoutException says only "The operation has timed out",
// and the one thing worth saying is where to look: a runtime this application does not install.
Status = "The terminal did not start, so nothing was connected. The Microsoft Edge WebView2 "
+ "runtime is probably missing or blocked; install it and try again.";
}
catch (SshHostKeyUnknownException exception)
{
// First contact. The user has to decide, and they need the fingerprint to do it.
@@ -2,6 +2,31 @@ using DodoSSH.Client.Ssh;
namespace DodoSSH.Client.Terminal;
/// <summary>Tuning for the workspace.</summary>
public sealed class TerminalWorkspaceOptions
{
/// <summary>
/// How long <see cref="TerminalWorkspace.WaitForRendererAsync"/> waits for the renderer's socket
/// before giving up.
/// </summary>
/// <remarks>
/// <para>
/// The value has to separate two cases. Attaching is normally near-instant: WebView2 starts with the
/// window and the page has usually attached while the user was still typing a passphrase. But a first
/// run on a cold profile creates a user-data directory and starts a process tree of some thirty-five
/// processes, and on a slow or loaded machine that is seconds rather than milliseconds. A renderer
/// that will never attach — no Evergreen runtime, an install blocked by policy, an AppContainer that
/// cannot reach loopback — will not attach however long the wait is.
/// </para>
/// <para>
/// So being generous costs only how long a genuinely broken WebView2 takes to say so, while being
/// tight costs telling someone their runtime is broken when it was merely slow. Fifteen seconds is
/// well clear of any cold start observed here and is still an answer rather than a hang.
/// </para>
/// </remarks>
public TimeSpan RendererTimeout { get; init; } = TimeSpan.FromSeconds(15);
}
/// <summary>
/// Owns the loopback data plane and every live terminal session.
/// </summary>
@@ -16,6 +41,7 @@ public sealed class TerminalWorkspace : IAsyncDisposable
private readonly TerminalDataPlane dataPlane;
private readonly ISshConnectionFactory connections;
private readonly TimeProvider clock;
private readonly TerminalWorkspaceOptions options;
private readonly Dictionary<uint, LiveSession> sessions = [];
private readonly CancellationTokenSource lifetime = new();
@@ -23,13 +49,19 @@ public sealed class TerminalWorkspace : IAsyncDisposable
private Task? server;
private int disposed;
/// <param name="assets">Where the renderer's files come from.</param>
/// <param name="connections">How SSH connections are made.</param>
/// <param name="clock">Time source, so the pumps' flush interval is testable.</param>
/// <param name="options">Tuning, or null for the defaults.</param>
public TerminalWorkspace(
ITerminalAssetProvider assets,
ISshConnectionFactory connections,
TimeProvider clock)
TimeProvider clock,
TerminalWorkspaceOptions? options = null)
{
this.connections = connections;
this.clock = clock;
this.options = options ?? new TerminalWorkspaceOptions();
dataPlane = new TerminalDataPlane(assets);
}
@@ -44,11 +76,25 @@ public sealed class TerminalWorkspace : IAsyncDisposable
/// Waits until the renderer page has attached its socket.
/// </summary>
/// <remarks>
/// A session opened before the renderer attaches would have its <c>SessionOpened</c> frame
/// dropped — the transport discards frames when nothing is connected — leaving output arriving
/// for a terminal that was never created.
/// <para>
/// A session opened before the renderer attaches would have its <c>SessionOpened</c> frame dropped —
/// the transport discards frames when nothing is connected — leaving output arriving for a terminal
/// that was never created. The gate is the invariant and stays.
/// </para>
/// <para>
/// Bounded, because whether the renderer attaches at all depends on a WebView2 runtime this process
/// does not install. An unbounded wait turned a missing runtime into a Connect that never returned,
/// with the caller's busy state never cleared and nothing on screen to explain it. Callers are
/// expected to translate the timeout into something that names the runtime, because
/// <see cref="TimeoutException"/>'s own message names nothing.
/// </para>
/// </remarks>
public Task WaitForRendererAsync() => dataPlane.RendererAttached;
/// <param name="cancellationToken">Abandons the wait.</param>
/// <exception cref="TimeoutException">
/// No renderer attached within <see cref="TerminalWorkspaceOptions.RendererTimeout"/>.
/// </exception>
public Task WaitForRendererAsync(CancellationToken cancellationToken) =>
dataPlane.RendererAttached.WaitAsync(options.RendererTimeout, cancellationToken);
/// <summary>Connects to a host and starts a terminal for it.</summary>
/// <returns>The session id, which identifies this terminal in the renderer.</returns>