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.
223 lines
6.4 KiB
JavaScript
223 lines
6.4 KiB
JavaScript
'use strict';
|
|
|
|
/*
|
|
The world terminal.js is loaded into by RendererReconnectionTests.
|
|
|
|
It fakes exactly what the page's transport touches and nothing else: a WebSocket whose every step is
|
|
driven from the test, a clock that only moves when a test moves it, and the two DOM objects the page
|
|
reads at load. There is no Terminal, no fit addon and no WebGL here, because nothing on the reconnection
|
|
path builds a pane — a test that opens a session will have to add them, and should, rather than this
|
|
file guessing now at what such a test would want.
|
|
|
|
◆ NOTHING HERE MAY DECLARE A NAME terminal.js ALSO DECLARES.
|
|
|
|
Both files are evaluated as scripts into the same global scope, and the page's own `const root` against
|
|
a `var root` here is a SyntaxError before a line of either runs. That is why the page's two elements are
|
|
`pageRoot` and `pageBanner` below and reached through getElementById, which is how the page reaches them
|
|
anyway.
|
|
|
|
◆ AND THE FAKE SOCKET'S BEHAVIOUR IS THE PART TO GET RIGHT.
|
|
|
|
close() is the one method with a rule that is not obvious and that the tests lean on: a socket closed
|
|
while it is still CONNECTING never fires 'close' at all, per the WebSocket specification, while one
|
|
closed after it opened does. The page relies on exactly that — connect() closes the attempt it abandons
|
|
and expects to hear nothing back from it — so a fake that fired 'close' either way would report the loop
|
|
the page's guards exist to prevent, and one that fired it neither way would hide it.
|
|
*/
|
|
|
|
var clock = { now: 0, next: 1, timers: {} };
|
|
|
|
/** Every socket the page has opened, in order, live or dead. */
|
|
var sockets = [];
|
|
|
|
function listeners(target) {
|
|
target.handlers = {};
|
|
|
|
target.addEventListener = function (name, handler) {
|
|
if (!target.handlers[name]) {
|
|
target.handlers[name] = [];
|
|
}
|
|
|
|
target.handlers[name].push(handler);
|
|
};
|
|
|
|
target.fire = function (name) {
|
|
var handlers = target.handlers[name] || [];
|
|
|
|
for (var i = 0; i < handlers.length; i++) {
|
|
handlers[i]({});
|
|
}
|
|
};
|
|
|
|
return target;
|
|
}
|
|
|
|
function FakeSocket(url, protocols) {
|
|
this.url = url;
|
|
this.protocols = protocols;
|
|
this.readyState = FakeSocket.CONNECTING;
|
|
this.binaryType = '';
|
|
|
|
listeners(this);
|
|
sockets.push(this);
|
|
}
|
|
|
|
FakeSocket.CONNECTING = 0;
|
|
FakeSocket.OPEN = 1;
|
|
FakeSocket.CLOSING = 2;
|
|
FakeSocket.CLOSED = 3;
|
|
|
|
FakeSocket.prototype.send = function () {};
|
|
|
|
/** What the page calls. See the note above on why a connecting socket goes quietly. */
|
|
FakeSocket.prototype.close = function () {
|
|
if (this.readyState === FakeSocket.CLOSED) {
|
|
return;
|
|
}
|
|
|
|
var wasConnecting = this.readyState === FakeSocket.CONNECTING;
|
|
this.readyState = FakeSocket.CLOSED;
|
|
|
|
if (!wasConnecting) {
|
|
this.fire('close');
|
|
}
|
|
};
|
|
|
|
var pageBanner = { textContent: 'Connecting…' };
|
|
|
|
var pageRoot = listeners({
|
|
dataset: { token: 'test-token', socket: 'ws://127.0.0.1:1/socket' },
|
|
appendChild: function () {},
|
|
});
|
|
|
|
var document = listeners({
|
|
hidden: false,
|
|
getElementById: function (id) { return id === 'root' ? pageRoot : pageBanner; },
|
|
createElement: function () { return { dataset: {}, style: {}, remove: function () {} }; },
|
|
});
|
|
|
|
var window = listeners({});
|
|
|
|
// The page warns through this on paths no reconnection test reaches. Present so that a test which does
|
|
// reach one fails on its own assertion rather than on a missing global.
|
|
var console = { warn: function () {}, log: function () {} };
|
|
|
|
function setTimeout(callback, delay) {
|
|
var id = clock.next++;
|
|
|
|
clock.timers[id] = { at: clock.now + (delay || 0), callback: callback };
|
|
|
|
return id;
|
|
}
|
|
|
|
function clearTimeout(id) {
|
|
delete clock.timers[id];
|
|
}
|
|
|
|
function ResizeObserver() {
|
|
this.observe = function () {};
|
|
}
|
|
|
|
// ── What the tests drive the page with ──────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Runs every timer due within the next `ms`, in the order they fall due.
|
|
*
|
|
* One at a time and re-scanned each round rather than collected up front, because a timer's callback
|
|
* routinely schedules the next one — which is the whole shape of the page's retry — and a snapshot taken
|
|
* before the first callback ran would miss it.
|
|
*/
|
|
function advance(ms) {
|
|
var target = clock.now + ms;
|
|
|
|
for (;;) {
|
|
var dueId = null;
|
|
var due = null;
|
|
|
|
for (var id in clock.timers) {
|
|
var timer = clock.timers[id];
|
|
|
|
if (timer.at <= target && (due === null || timer.at < due.at)) {
|
|
due = timer;
|
|
dueId = id;
|
|
}
|
|
}
|
|
|
|
if (due === null) {
|
|
break;
|
|
}
|
|
|
|
delete clock.timers[dueId];
|
|
clock.now = due.at;
|
|
due.callback();
|
|
}
|
|
|
|
clock.now = target;
|
|
}
|
|
|
|
/** How many sockets the page has opened since it loaded. */
|
|
function attempts() {
|
|
return sockets.length;
|
|
}
|
|
|
|
/** What the one status element says — the banner the user sees. */
|
|
function banner() {
|
|
return pageBanner.textContent;
|
|
}
|
|
|
|
/** Whether that attempt has been closed, by the page or by the far end. */
|
|
function isClosed(index) {
|
|
return sockets[index].readyState === FakeSocket.CLOSED;
|
|
}
|
|
|
|
/** The host accepted the upgrade. */
|
|
function accept(index) {
|
|
sockets[index].readyState = FakeSocket.OPEN;
|
|
sockets[index].fire('open');
|
|
}
|
|
|
|
/** An established socket goes away and the page is told. */
|
|
function drop(index) {
|
|
sockets[index].readyState = FakeSocket.CLOSED;
|
|
sockets[index].fire('close');
|
|
}
|
|
|
|
/** An attempt that never connects: 'error' then 'close', as a refused connection reports itself. */
|
|
function fail(index) {
|
|
sockets[index].readyState = FakeSocket.CLOSED;
|
|
sockets[index].fire('error');
|
|
sockets[index].fire('close');
|
|
}
|
|
|
|
/**
|
|
* A close arriving for a socket that died earlier — the host's takeover abort, landing late.
|
|
*
|
|
* Separate from drop() because the point of it is the delay: the socket is already dead by the time the
|
|
* event is delivered, which is what a suspended renderer's queued events look like on the way back.
|
|
*/
|
|
function deliverLateClose(index) {
|
|
sockets[index].readyState = FakeSocket.CLOSED;
|
|
sockets[index].fire('close');
|
|
}
|
|
|
|
/** The screen the page is on goes away, and comes back. */
|
|
function becomeHidden() {
|
|
document.hidden = true;
|
|
document.fire('visibilitychange');
|
|
}
|
|
|
|
function becomeVisible() {
|
|
document.hidden = false;
|
|
document.fire('visibilitychange');
|
|
}
|
|
|
|
function takeFocus() {
|
|
window.fire('focus');
|
|
}
|
|
|
|
function comeOnline() {
|
|
window.fire('online');
|
|
}
|
|
|
|
var WebSocket = FakeSocket;
|