Public Access
Reconnect the terminal view when somebody comes back to it
Going back to a terminal left alone for a while found it stuck on "Reconnecting the terminal view…", and stuck is the right word: the banner stayed and nothing behind it was reconnecting. The page's whole recovery story was a setTimeout chain, and a chain is exactly what a WebView is entitled to stop running. Chromium throttles timers in a page nobody is looking at — down to once a minute once it has been hidden five minutes — and a renderer that is frozen, or reclaimed and not yet reloaded, runs none of them. So the socket drops while nobody is watching, the banner goes up, the retry is scheduled, and the retry is then the one thing not running. Three defects, each of which leaves that banner up for the rest of the page's life. ◆ NOTHING LISTENED FOR THE PAGE COMING BACK. The only thing that could clear the banner was a timer that may never fire. terminal.js now reconnects on visibilitychange, focus and online — the events that mean somebody is looking again, and the ones that cannot be throttled — cancelling the pending timer and resetting the backoff. Over a healthy socket all three do nothing, which is what makes them safe to fire as often as clicking a window does. ◆ A HANDSHAKE THAT NEVER FINISHED WAS INVISIBLE. Every retry was scheduled by a close or an error, so an attempt parked in CONNECTING — which is what a suspended renderer leaves behind — scheduled nothing at all, ever. There is now a five-second watchdog on the handshake. ◆ STALE SOCKETS SCHEDULED RETRIES, AND THAT ONE IS A LOOP RATHER THAN A STALL. connect() never detached the old socket's handlers, and the host aborts the displaced socket on takeover — TerminalDataPlane.UpgradeAsync, doing exactly what it should. That close read as a fresh failure and scheduled a retry against the socket that had just succeeded, whose own close scheduled the next: no fixed point, reconnecting every second forever with the banner up for most of it. Every handler now asks whether it is still the page's own attempt, and connect() closes what it abandons. ◆ WHICH OF THE PLATFORM BEHAVIOURS ACTUALLY BIT IS NOT ESTABLISHED, and the fix does not depend on knowing. Throttled timers, a frozen renderer and a reclaimed one all end at the same dead timer; guessing between them would have produced a narrower fix for one of the three. THE TEST RUNS terminal.js ITSELF, in a fake browser, inside dotnet test. RendererPage loads the file the shell project ships — not a transcription of its logic into C#, which would be a copy that stays correct while the page rots — into a Jint engine, one per test, over a harness that fakes a WebSocket and a clock and nothing else. Jint rather than a node script because CI would run the node one and nobody's inner loop would; the cost is that Jint is not Chromium, so this proves the page's logic and nothing about how a WebView behaves. That line is drawn in RendererPage's remark and picked up by two new manual checks, 1.10 for the desktop and 11.12a for the phone, which own the platform half. Four of the nine tests fail against the page as it stood — the stale close, the parked handshake, and the two wake-ups. Two more assert that a wake-up over a healthy socket does nothing, and pass against either version on purpose: they are what stops the cure being worse. Left alone deliberately: a socket that is open and dead shows no banner at all, because readyState still reads OPEN. That looks like a terminal that swallows what is typed, needs a liveness probe rather than a faster retry, and is written down at the end of 11.12a rather than quietly bundled in here.
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
using System.Globalization;
|
||||
|
||||
using Jint;
|
||||
|
||||
namespace DodoSSH.Client.Terminal.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// One load of <c>terminal.js</c>, in a fake browser, for one test.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The page's own source is what runs.</b> Not a transcription of its logic into C# — that would test a
|
||||
/// copy, and the copy would be the thing that stayed correct. The file is read from the shell project and
|
||||
/// evaluated as it ships, so a change to the page that breaks the reconnection rules fails here.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Jint rather than node, and that is a deliberate trade.</b> A node script would be the obvious way to
|
||||
/// run JavaScript and would need node on every machine and in every CI job that runs the suite — so it
|
||||
/// would be a second test command, run separately, and the first thing to be forgotten. This runs inside
|
||||
/// <c>dotnet test</c> with everything else. What it costs is that the engine is not the engine the page
|
||||
/// actually runs in: Jint is not Chromium, so this can prove the page's own logic and can prove nothing
|
||||
/// about how WebView2 or Android's WebView behave. That boundary is exactly where
|
||||
/// <c>docs/manual-checks.md</c> picks up — see 1.10 and 11.12a.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A fresh engine per test, because the page is a script with module-level state and there is no unloading
|
||||
/// it: two tests sharing one engine would share a socket list, a clock and a backoff.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class RendererPage
|
||||
{
|
||||
private readonly Engine engine;
|
||||
|
||||
private RendererPage(Engine engine) => this.engine = engine;
|
||||
|
||||
/// <summary>How many sockets the page has opened since it loaded.</summary>
|
||||
/// <remarks>
|
||||
/// The measurement nearly every test here turns on, and it is deliberately a count of *attempts*
|
||||
/// rather than of live sockets: the failures being guarded against are a page that stops trying and a
|
||||
/// page that never stops, and both are counted rather than observed.
|
||||
/// </remarks>
|
||||
public int Attempts => (int)engine.Evaluate("attempts()").AsNumber();
|
||||
|
||||
/// <summary>What the status element says — the banner a user would be looking at.</summary>
|
||||
public string Banner => engine.Evaluate("banner()").AsString();
|
||||
|
||||
/// <summary>
|
||||
/// Loads the harness and then the page, leaving the page exactly as it is a moment after the WebView
|
||||
/// navigated to it: one socket opened and still connecting.
|
||||
/// </summary>
|
||||
public static RendererPage Load()
|
||||
{
|
||||
var browser = new Engine();
|
||||
|
||||
// Order matters and is not incidental: the page connects on its last line, so every global it
|
||||
// touches — the socket constructor above all — has to be in place before it is evaluated.
|
||||
browser.Execute(Read("Renderer/renderer-harness.js"));
|
||||
browser.Execute(Read("Renderer/terminal.js"));
|
||||
|
||||
return new RendererPage(browser);
|
||||
}
|
||||
|
||||
/// <summary>Runs a line of the harness's own vocabulary — <c>accept(0)</c>, <c>advance(1000)</c>.</summary>
|
||||
public void Do(string script) => engine.Execute(script);
|
||||
|
||||
/// <summary>Whether that attempt has been closed, by the page or by the far end.</summary>
|
||||
public bool IsClosed(int attempt) =>
|
||||
engine.Evaluate(
|
||||
string.Create(CultureInfo.InvariantCulture, $"isClosed({attempt})"))
|
||||
.AsBoolean();
|
||||
|
||||
/// <remarks>
|
||||
/// Both files are copied beside the test assembly by the project file, the page out of the shell
|
||||
/// project it belongs to. Read from disk rather than embedded so that the copy which runs here is the
|
||||
/// same bytes the host serves, with nothing in between that could go stale.
|
||||
/// </remarks>
|
||||
private static string Read(string relativePath) =>
|
||||
File.ReadAllText(Path.Combine(AppContext.BaseDirectory, relativePath));
|
||||
}
|
||||
Reference in New Issue
Block a user