Public Access
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.
193 lines
6.9 KiB
C#
193 lines
6.9 KiB
C#
namespace DodoSSH.Client.Terminal.Tests;
|
|
|
|
/// <summary>
|
|
/// The renderer page's half of staying attached — <c>terminal.js</c>'s <c>connect()</c> and what drives it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The host's half is <see cref="TerminalDataPlaneTests"/>, and the two are one mechanism: a socket that
|
|
/// drops is ordinary here, and the page coming back for another is what makes it ordinary. What these
|
|
/// tests protect is the property that failure of this mechanism has no other symptom — a terminal whose
|
|
/// page has given up looks exactly like a terminal whose remote has gone quiet, except for a line of text
|
|
/// nobody reads twice.
|
|
/// </para>
|
|
/// <para>
|
|
/// Four of these were written against a page that failed them — the stale close, the handshake that never
|
|
/// finishes, and the two wake-ups — and the rest describe behaviour that was already right and is easy to
|
|
/// break while fixing those. The two that assert a wake-up does *nothing* pass against either version,
|
|
/// which is the point of them: they are what stops the cure being worse, and they can only ever fail
|
|
/// against a future change. See <see cref="RendererPage"/> for how the real file is loaded and for what
|
|
/// this cannot reach.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class RendererReconnectionTests
|
|
{
|
|
[Fact]
|
|
public void ThePage_ConnectsWhenItLoads()
|
|
{
|
|
var page = RendererPage.Load();
|
|
|
|
page.Attempts.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public void ADroppedSocket_IsRetriedAndTheBannerClears()
|
|
{
|
|
var page = RendererPage.Load();
|
|
|
|
page.Do("accept(0)");
|
|
page.Banner.ShouldBe("");
|
|
|
|
page.Do("drop(0)");
|
|
page.Banner.ShouldStartWith("Reconnecting");
|
|
|
|
page.Do("advance(1000)");
|
|
page.Attempts.ShouldBe(2);
|
|
|
|
page.Do("accept(1)");
|
|
page.Banner.ShouldBe("");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The wait grows within one outage and goes back to a second once a socket has actually opened, so
|
|
/// that the next outage is not paid for at the previous one's rate.
|
|
/// </remarks>
|
|
[Fact]
|
|
public void TheWait_GrowsWithinAnOutageAndResetsAfterIt()
|
|
{
|
|
var page = RendererPage.Load();
|
|
|
|
page.Do("fail(0); advance(1000)");
|
|
page.Attempts.ShouldBe(2);
|
|
|
|
page.Do("fail(1); advance(1999)");
|
|
page.Attempts.ShouldBe(2);
|
|
|
|
page.Do("advance(1)");
|
|
page.Attempts.ShouldBe(3);
|
|
|
|
page.Do("accept(2); drop(2); advance(1000)");
|
|
page.Attempts.ShouldBe(4);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A close for a socket the page has already replaced must not start a reconnect.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The loop this forbids costs nothing to enter and never leaves: the host aborts the displaced socket
|
|
/// on every takeover — see <c>TerminalDataPlane.UpgradeAsync</c> — so a stale close that schedules a
|
|
/// retry displaces the socket that has just succeeded, whose own close schedules the next. The visible
|
|
/// end of it is a terminal that reconnects every second forever with the banner up for most of it.
|
|
/// </remarks>
|
|
[Fact]
|
|
public void AStaleClose_DoesNotDisplaceTheSocketThatSucceeded()
|
|
{
|
|
var page = RendererPage.Load();
|
|
|
|
page.Do("accept(0); drop(0); advance(1000)");
|
|
page.Do("accept(1)");
|
|
page.Attempts.ShouldBe(2);
|
|
|
|
// The first socket's end, arriving after the page has moved on.
|
|
page.Do("deliverLateClose(0)");
|
|
page.Do("advance(60000)");
|
|
|
|
page.Attempts.ShouldBe(2);
|
|
page.Banner.ShouldBe("");
|
|
}
|
|
|
|
/// <summary>
|
|
/// An attempt that never finishes its handshake is given up on rather than waited on forever.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Every other retry in the page is scheduled by a close or an error, so a socket that reports neither
|
|
/// — which is what a renderer suspended mid-handshake leaves behind — used to schedule nothing at all.
|
|
/// The page then held a banner saying it was reconnecting with no timer pending and no socket coming,
|
|
/// for the rest of its life.
|
|
/// </remarks>
|
|
[Fact]
|
|
public void AHandshakeThatNeverFinishes_IsAbandonedAndRetried()
|
|
{
|
|
var page = RendererPage.Load();
|
|
|
|
// Nothing whatever from the first attempt: no open, no error, no close.
|
|
page.Do("advance(5000)");
|
|
page.Attempts.ShouldBe(1);
|
|
|
|
page.Do("advance(1000)");
|
|
page.Attempts.ShouldBe(2);
|
|
page.IsClosed(0).ShouldBeTrue();
|
|
|
|
page.Do("accept(1)");
|
|
page.Banner.ShouldBe("");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Coming back to the page reconnects it, without waiting for a timer that may not be running.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The case the whole wake-up path exists for, and the one a test can only approximate: the harness's
|
|
/// clock stands still here because a real hidden page's clock is throttled rather than stopped, and
|
|
/// standing still is the honest worst case of that. What is being asserted is that the page does not
|
|
/// need the clock at all to notice it is back.
|
|
/// </remarks>
|
|
[Fact]
|
|
public void BecomingVisibleAgain_ReconnectsWithoutTheTimer()
|
|
{
|
|
var page = RendererPage.Load();
|
|
|
|
page.Do("accept(0); becomeHidden(); drop(0)");
|
|
page.Attempts.ShouldBe(1);
|
|
|
|
page.Do("becomeVisible()");
|
|
page.Attempts.ShouldBe(2);
|
|
|
|
// And the timer that was pending when the page woke must not open a third socket on top of the
|
|
// one that just succeeded — which would be the takeover loop, entered from the other side.
|
|
page.Do("accept(1); advance(60000)");
|
|
page.Attempts.ShouldBe(2);
|
|
page.Banner.ShouldBe("");
|
|
}
|
|
|
|
[Fact]
|
|
public void TakingTheKeyboardBack_AlsoReconnects()
|
|
{
|
|
var page = RendererPage.Load();
|
|
|
|
page.Do("accept(0); drop(0); takeFocus()");
|
|
|
|
page.Attempts.ShouldBe(2);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The wake-ups fire on gestures as ordinary as clicking the window, so the check they make has to be
|
|
/// the thing that keeps them cheap rather than the frequency. A page whose socket is up must treat all
|
|
/// of them as nothing at all — anything else would be the takeover loop with a person's mouse driving it.
|
|
/// </remarks>
|
|
[Fact]
|
|
public void WakingUpOverAHealthySocket_DoesNothing()
|
|
{
|
|
var page = RendererPage.Load();
|
|
|
|
page.Do("accept(0)");
|
|
page.Do("takeFocus(); becomeVisible(); comeOnline(); becomeHidden(); becomeVisible()");
|
|
|
|
page.Attempts.ShouldBe(1);
|
|
page.Banner.ShouldBe("");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// An attempt already in flight is left to finish or to time out. Restarting it on every wake-up would
|
|
/// mean a page being clicked during a slow handshake never completing one.
|
|
/// </remarks>
|
|
[Fact]
|
|
public void WakingUpWhileConnecting_LeavesTheAttemptAlone()
|
|
{
|
|
var page = RendererPage.Load();
|
|
|
|
page.Do("takeFocus(); becomeVisible(); comeOnline()");
|
|
|
|
page.Attempts.ShouldBe(1);
|
|
}
|
|
}
|