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.
812 lines
34 KiB
JavaScript
812 lines
34 KiB
JavaScript
'use strict';
|
|
|
|
/*
|
|
The renderer half of the terminal data plane.
|
|
|
|
Three things here are load-bearing and easy to get wrong:
|
|
|
|
1. Output is acknowledged from term.write's completion callback, never on receipt. The
|
|
acknowledgement returns flow-control credit to the host, so acknowledging early would tell
|
|
the host the screen has caught up when it has not — and the whole point of the credit
|
|
window is that it reflects what has actually been rendered. Acknowledge on receipt and a
|
|
remote running `yes` grows this page's memory until the tab dies.
|
|
|
|
2. Output is written as a Uint8Array, not a string. xterm decodes UTF-8 itself and carries
|
|
partial sequences across writes. Decoding here would corrupt any multi-byte character that
|
|
happened to straddle a frame boundary, which shows up as occasional mojibake in exactly the
|
|
conditions that are hardest to reproduce.
|
|
|
|
3. The socket reconnects itself, forever, with backoff — and on being looked at again, which is not
|
|
the same thing and is the half a timer cannot cover. This page's WebView is routinely killed
|
|
and reloaded by Android under memory pressure or simply for being backgrounded, so "the
|
|
socket closed" is an ordinary event here, not the end of the terminal's life — see connect().
|
|
A reloaded page starts with an empty session map, so createSession is idempotent (a session
|
|
that already has a pane is left alone) and a SESSION_OPENED frame carries a flag telling this
|
|
page whether it is a replay: nothing to do for a pane that is still here, and a short banner
|
|
for one that is not, because that pane's scrollback genuinely did not survive.
|
|
*/
|
|
|
|
const SERVER_OUTPUT = 1;
|
|
const SERVER_SESSION_OPENED = 2;
|
|
const SERVER_SESSION_CLOSED = 3;
|
|
const SERVER_SESSION_ACTIVATED = 4;
|
|
const SERVER_SESSION_REMOVED = 5;
|
|
const SERVER_PASTE = 6;
|
|
const SERVER_FONT_SIZE = 7;
|
|
|
|
const CLIENT_INPUT = 1;
|
|
const CLIENT_ACKNOWLEDGE = 2;
|
|
const CLIENT_RESIZE = 3;
|
|
const CLIENT_FONT_SIZE_STEP = 4;
|
|
|
|
const HEADER_LENGTH = 5;
|
|
const SCROLLBACK_LINES = 5000;
|
|
|
|
/*
|
|
How long to wait before trying the socket again, and how that wait grows. Starting quick matters
|
|
because the ordinary case is a page that just finished loading after its WebView came back — the
|
|
host's listener has been sitting there the whole time — and capping it matters because there is no
|
|
point spacing attempts further apart than a person notices. Forever rather than giving up, because
|
|
giving up would need a way to try again and there is none better than the one already here: the page
|
|
dies with the app.
|
|
*/
|
|
const RECONNECT_INITIAL_DELAY_MS = 1000;
|
|
const RECONNECT_MAX_DELAY_MS = 5000;
|
|
|
|
/*
|
|
How long a socket is given to finish its handshake before it is treated as a failure.
|
|
|
|
A socket that cannot connect normally says so and says it quickly — 'error' then 'close', within a
|
|
millisecond or two of a loopback refusal. The case this covers is the one that says nothing: an attempt
|
|
parked in CONNECTING with no event ever coming, which is what a WebSocket opened by a renderer that is
|
|
then suspended, or one whose handshake the host never answers, leaves this page holding.
|
|
|
|
Without the watchdog that state is terminal, and quietly so. Every retry in this file is scheduled by a
|
|
close or an error, so an attempt that produces neither schedules nothing: the banner says the view is
|
|
reconnecting for the rest of the page's life while nothing whatever is reconnecting. Five seconds is
|
|
generous against a handshake that ordinarily takes about a millisecond, and short against somebody
|
|
waiting on their terminal to come back.
|
|
*/
|
|
const HANDSHAKE_TIMEOUT_MS = 5000;
|
|
|
|
/*
|
|
Styled like the SESSION_CLOSED banner (matching \x1b[38;5;244, the same dim grey), but written by
|
|
createSession's caller rather than by createSession itself: only a *replay* landing on a pane that
|
|
does not exist yet means the page reloaded and lost it, and createSession has no way to know which
|
|
of its callers that is.
|
|
*/
|
|
const REPLAY_BANNER = '\x1b[38;5;244m── the view reconnected; earlier output stayed on the host ──\x1b[0m\r\n';
|
|
|
|
/*
|
|
The size panes are created at, until the host says otherwise — which it does as soon as it has read
|
|
the stored preference, usually before the first session exists. Kept here as well so a pane opened
|
|
before that frame arrives is not created at some other size and then jumped.
|
|
*/
|
|
const DEFAULT_FONT_SIZE = 13;
|
|
let fontSize = DEFAULT_FONT_SIZE;
|
|
|
|
/*
|
|
The way out of the terminal, for someone using only a keyboard.
|
|
|
|
It has to be handled here rather than by the host: once this page's window owns Win32 focus, the
|
|
host's Avalonia window receives no key events at all, so nothing on that side could hear a shortcut.
|
|
|
|
Ctrl+Shift+F6 rather than Escape. F6 is the Windows convention for moving to the next pane, but a
|
|
bare F6 is a real terminal key that TUIs bind — as is Escape, which vim alone rules out. Ctrl+Shift
|
|
is the range terminal emulators conventionally keep for themselves and never forward to the remote,
|
|
so qualifying F6 with it keeps the convention without taking a key away from the remote shell.
|
|
*/
|
|
const RELEASE_FOCUS_MESSAGE = 'dodossh.release-focus';
|
|
|
|
const root = document.getElementById('root');
|
|
const statusBanner = document.getElementById('status');
|
|
|
|
/** @type {Map<number, {term: object, fit: object, pane: HTMLElement, notice: string}>} */
|
|
const sessions = new Map();
|
|
|
|
/** @type {WebSocket | null} */
|
|
let socket = null;
|
|
|
|
/** Whose pane is showing, or null before there is one — see activate(). */
|
|
let activeSessionId = null;
|
|
|
|
/*
|
|
── THE BANNER BELONGS TO ONE PANE AT A TIME ─────────────────────────────────────────────────────────
|
|
There is one #status element for the whole page, because there is one page for every terminal: the
|
|
panes are stacked in the same box and all but the active one are hidden. What goes in it comes from
|
|
two sources that are not the same size, and the difference is the whole of this.
|
|
|
|
The socket's troubles are the page's. There is a single socket behind every pane, so "the view is
|
|
reconnecting" is true of whatever is on screen and true of the panes behind it.
|
|
|
|
A session's last words are not. "The remote closed the session." is a fact about one terminal and says
|
|
nothing whatever about the others — so it is held on the session and drawn only while that session's
|
|
pane is the one showing. Written straight into the shared element, which is what this used to do, it
|
|
outlived the tab it described: switching to a live terminal left the dead one's epitaph sitting under
|
|
it, and opening or closing any other tab wiped the message whether or not it belonged to that tab.
|
|
|
|
The socket's half wins when both have something to say: a page whose socket is down is not showing
|
|
live output on any pane, which makes what became of one session the less urgent of the two.
|
|
*/
|
|
let transportStatus = statusBanner.textContent ?? '';
|
|
|
|
function renderStatus() {
|
|
const notice = activeSessionId === null ? '' : sessions.get(activeSessionId)?.notice ?? '';
|
|
|
|
statusBanner.textContent = transportStatus || notice;
|
|
}
|
|
|
|
/** Says something about the socket, which every pane shares. */
|
|
function setTransportStatus(text) {
|
|
transportStatus = text ?? '';
|
|
renderStatus();
|
|
}
|
|
|
|
/** Records what became of one session, to be drawn only while that session's pane is showing. */
|
|
function setSessionNotice(sessionId, text) {
|
|
const session = sessions.get(sessionId);
|
|
|
|
if (!session) {
|
|
return;
|
|
}
|
|
|
|
session.notice = text ?? '';
|
|
renderStatus();
|
|
}
|
|
|
|
/** Builds a frame: opcode, big-endian session id, then payload. */
|
|
function frame(opcode, sessionId, payload) {
|
|
const body = payload ?? new Uint8Array(0);
|
|
const buffer = new ArrayBuffer(HEADER_LENGTH + body.length);
|
|
const view = new DataView(buffer);
|
|
|
|
view.setUint8(0, opcode);
|
|
view.setUint32(1, sessionId);
|
|
new Uint8Array(buffer, HEADER_LENGTH).set(body);
|
|
|
|
return buffer;
|
|
}
|
|
|
|
function send(opcode, sessionId, payload) {
|
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
|
socket.send(frame(opcode, sessionId, payload));
|
|
}
|
|
}
|
|
|
|
function sendAcknowledgement(sessionId, byteCount) {
|
|
const payload = new Uint8Array(4);
|
|
new DataView(payload.buffer).setUint32(0, byteCount);
|
|
send(CLIENT_ACKNOWLEDGE, sessionId, payload);
|
|
}
|
|
|
|
function sendResize(sessionId, term, pane) {
|
|
const payload = new Uint8Array(8);
|
|
const view = new DataView(payload.buffer);
|
|
|
|
// Columns before rows, matching the SSH request. Swapping them produces a terminal that is 24
|
|
// columns by 80 rows, which reads as a rendering bug rather than a protocol one.
|
|
view.setUint16(0, term.cols);
|
|
view.setUint16(2, term.rows);
|
|
view.setUint16(4, pane.clientWidth);
|
|
view.setUint16(6, pane.clientHeight);
|
|
|
|
send(CLIENT_RESIZE, sessionId, payload);
|
|
}
|
|
|
|
/**
|
|
* Asks the host to take keyboard focus back.
|
|
*
|
|
* Optional by design: the bridge only exists under a real embedded WebView, and this page is also
|
|
* openable in a plain browser for debugging, where there is no host to ask.
|
|
*/
|
|
function releaseFocusToHost() {
|
|
window.chrome?.webview?.postMessage(RELEASE_FOCUS_MESSAGE);
|
|
}
|
|
|
|
/**
|
|
* Asks the host to move the font size, or to put it back (step 0).
|
|
*
|
|
* A request rather than a change made here: the host owns the size, because the host is what remembers
|
|
* it between launches and what draws the buttons the phone uses. The answer arrives as a
|
|
* SERVER_FONT_SIZE frame, so this route and that one end in the same place.
|
|
*/
|
|
function requestFontSizeStep(step) {
|
|
const payload = new Uint8Array(1);
|
|
new DataView(payload.buffer).setInt8(0, step);
|
|
|
|
// Session zero: the size is not a property of any one terminal.
|
|
send(CLIENT_FONT_SIZE_STEP, 0, payload);
|
|
}
|
|
|
|
/**
|
|
* Applies a size to every pane, and to panes opened after this.
|
|
*
|
|
* Refitting is not optional. The cell size has changed, so the column and row counts have too, and a
|
|
* pane left unfitted draws a grid the remote is not wrapping to. fit() sends the resize frame that
|
|
* tells the far end.
|
|
*/
|
|
function applyFontSize(size) {
|
|
fontSize = size;
|
|
|
|
for (const [sessionId, session] of sessions) {
|
|
session.term.options.fontSize = size;
|
|
resize(session, sessionId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Swallows the shortcuts that belong to the terminal application rather than to the remote.
|
|
*
|
|
* Returning false stops xterm processing the event, which is what keeps a chord from being encoded
|
|
* and written to the pty.
|
|
*/
|
|
function handleKey(event) {
|
|
if (event.type !== 'keydown') {
|
|
return true;
|
|
}
|
|
|
|
if (event.ctrlKey && event.shiftKey && event.key === 'F6') {
|
|
releaseFocusToHost();
|
|
return false;
|
|
}
|
|
|
|
/*
|
|
Ctrl with plus, minus and zero — what every terminal emulator and every browser uses for text size,
|
|
and it has to be caught here for the reason the release-focus chord does: while a terminal has focus
|
|
the host's window receives no key events at all, so nothing on that side could hear it.
|
|
|
|
Both spellings of plus, because the key that is drawn as + on the keycap reports as '+' when Shift
|
|
is read and as '=' when it is not, and which one arrives is not something the person pressing it
|
|
should have to know. Same for minus and underscore.
|
|
*/
|
|
if (event.ctrlKey && !event.altKey) {
|
|
if (event.key === '+' || event.key === '=') {
|
|
requestFontSizeStep(1);
|
|
return false;
|
|
}
|
|
|
|
if (event.key === '-' || event.key === '_') {
|
|
requestFontSizeStep(-1);
|
|
return false;
|
|
}
|
|
|
|
if (event.key === '0') {
|
|
requestFontSizeStep(0);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Builds a pane for a session, or returns the one already there.
|
|
*
|
|
* Idempotent because a replay can land on a page that never lost its pane — the socket dropped and
|
|
* came back, but this page's own process survived — and asking for a session that already has a pane
|
|
* must not build a second one on top of it, orphaning the first one's WebGL context and scrollback.
|
|
*/
|
|
function createSession(sessionId) {
|
|
const existing = sessions.get(sessionId);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
|
|
const pane = document.createElement('div');
|
|
pane.className = 'pane';
|
|
pane.dataset.sessionId = String(sessionId);
|
|
root.appendChild(pane);
|
|
|
|
const term = new Terminal({
|
|
allowProposedApi: true,
|
|
convertEol: false,
|
|
cursorBlink: true,
|
|
fontSize,
|
|
scrollback: SCROLLBACK_LINES,
|
|
// Matches terminal.css, so the canvas and the page agree on the background. Both are the v2
|
|
// design's terminal surface — `TerminalSurface` in Palette.axaml. See the note in terminal.css.
|
|
theme: { background: '#171a26', foreground: '#d8dcea' },
|
|
});
|
|
|
|
const fit = new FitAddon.FitAddon();
|
|
term.loadAddon(fit);
|
|
term.open(pane);
|
|
|
|
// WebGL where it is available. Falling back rather than failing matters because a software
|
|
// renderer is slow but usable, whereas a blank pane is not — and remote desktops and VMs
|
|
// routinely have no usable GPU context.
|
|
//
|
|
// ◆ THE CONTEXT-LOSS HANDLER IS THE HALF THAT WAS MISSING, AND ON A PHONE IT IS THE WHOLE THING.
|
|
//
|
|
// The addon does not recover from a lost GPU context by itself, and it does not fail loudly either:
|
|
// it stays loaded over a dead context and draws nothing at all. What that looks like from outside is
|
|
// a terminal that is connected, still accepting keystrokes, still acknowledging output — and blank.
|
|
// xterm's own guidance is to dispose the addon and let the DOM renderer take over, which is what this
|
|
// does; the addon is not reloaded afterwards, because a pane that lost the context once is on a
|
|
// surface that will do it again and thrashing between renderers is worse than being slow.
|
|
//
|
|
// Losing it is ordinary on Android and nearly unheard of on Windows, which is why this went unnoticed
|
|
// for so long. Collapsing the renderer sets the native view to GONE — see
|
|
// AndroidNativeControlHostImpl.HideWithSize — and a WebView with no surface has no GL context. The
|
|
// shell collapses it every time a tab starts connecting, every time the connect sheet opens and every
|
|
// time the app is backgrounded, so on a phone the first loss arrives within seconds of the first
|
|
// session. WebView2 hides a child HWND instead and keeps rendering throughout; see
|
|
// docs/platform-flags.md.
|
|
try {
|
|
const webgl = new WebglAddon.WebglAddon();
|
|
|
|
// Subscribed before loadAddon, because loadAddon is what activates the addon and a context that is
|
|
// already gone can be reported from inside that call.
|
|
webgl.onContextLoss(() => webgl.dispose());
|
|
|
|
term.loadAddon(webgl);
|
|
} catch (error) {
|
|
console.warn('WebGL renderer unavailable; falling back to canvas.', error);
|
|
}
|
|
|
|
term.attachCustomKeyEventHandler(handleKey);
|
|
|
|
term.onData((data) => {
|
|
send(CLIENT_INPUT, sessionId, new TextEncoder().encode(data));
|
|
});
|
|
|
|
term.onResize(() => sendResize(sessionId, term, pane));
|
|
|
|
const session = { term, fit, pane, notice: '' };
|
|
sessions.set(sessionId, session);
|
|
|
|
activate(sessionId);
|
|
resize(session, sessionId);
|
|
|
|
return session;
|
|
}
|
|
|
|
function activate(sessionId) {
|
|
for (const [id, session] of sessions) {
|
|
session.pane.dataset.active = String(id === sessionId);
|
|
}
|
|
|
|
// The banner follows the pane. Whatever this session has to say for itself replaces whatever the
|
|
// session that was showing had to say for its own, which is the point of holding it per session.
|
|
activeSessionId = sessionId;
|
|
renderStatus();
|
|
|
|
const active = sessions.get(sessionId);
|
|
if (active) {
|
|
active.term.focus();
|
|
}
|
|
}
|
|
|
|
// Below this, a pane is not being looked at — it is minimised or dragged to nothing. Fitting anyway would
|
|
// be actively harmful rather than merely useless: the fit addon floors its proposal at 2 columns by 1 row,
|
|
// so a degenerate viewport reflows the *remote* pty to 2x1 through window-change, and the wrapped
|
|
// scrollback that produces cannot be recovered when the pane comes back. A guard rather than a fix for one
|
|
// caller, because more than one path reaches here: a minimised window, and a splitter dragged to the edge
|
|
// once splits land.
|
|
//
|
|
// It is *not* what protects the vault's lock screen on the desktop, which an earlier version of this
|
|
// comment claimed. Collapsing WebView2 hides a native child window without resizing it, so this page's
|
|
// viewport does not change, no observer fires and this function is never called — measured with a live
|
|
// shell, and confirmed by removing the guard and finding the lock cycle equally clean. See
|
|
// docs/platform-flags.md.
|
|
//
|
|
// On the phone it *is* load-bearing, and that is the one place the two heads differ here. Android hides a
|
|
// native child by setting it GONE, and a GONE view is skipped by its parent's layout — so collapsing the
|
|
// renderer really does take this page's viewport to nothing, the observer really does fire, and without
|
|
// the guard every lock, every connect sheet and every trip to the background would reflow the remote pty
|
|
// to 2x1 and mangle the scrollback it wrapped.
|
|
const MINIMUM_FITTABLE_PIXELS = 40;
|
|
|
|
function resize(session, sessionId) {
|
|
const pane = session.pane;
|
|
|
|
if (pane.clientWidth < MINIMUM_FITTABLE_PIXELS || pane.clientHeight < MINIMUM_FITTABLE_PIXELS) {
|
|
return;
|
|
}
|
|
|
|
// fit() throws if the pane has no layout yet, which happens on the very first frame.
|
|
try {
|
|
session.fit.fit();
|
|
sendResize(sessionId, session.term, session.pane);
|
|
} catch (error) {
|
|
console.warn('Could not fit the terminal yet.', error);
|
|
}
|
|
}
|
|
|
|
function handleFrame(buffer) {
|
|
if (buffer.byteLength < HEADER_LENGTH) {
|
|
return;
|
|
}
|
|
|
|
const view = new DataView(buffer);
|
|
const opcode = view.getUint8(0);
|
|
const sessionId = view.getUint32(1);
|
|
const payload = new Uint8Array(buffer, HEADER_LENGTH);
|
|
|
|
switch (opcode) {
|
|
case SERVER_SESSION_OPENED: {
|
|
// Checked before createSession, which would otherwise erase the answer by creating the pane
|
|
// this check is asking about.
|
|
const hadPaneAlready = sessions.has(sessionId);
|
|
const session = createSession(sessionId);
|
|
|
|
// Byte 1 means the host is replaying a session that existed before this socket attached — see
|
|
// TerminalWorkspace.ReplayAfterAttachAsync. A replay landing on a pane that is still here has
|
|
// nothing left to do beyond the idempotent create above; one landing on a pane that is not means
|
|
// this page reloaded and that pane's scrollback went with it, which is worth a line saying so.
|
|
if (payload.length > 0 && payload[0] === 1 && !hadPaneAlready) {
|
|
session.term.write(REPLAY_BANNER);
|
|
}
|
|
|
|
// This session's own line, and only this one's: a session that is open has nothing to say about
|
|
// how it ended. The page's own "Connecting…" is cleared by the socket opening, which happens
|
|
// before any frame can arrive.
|
|
setSessionNotice(sessionId, '');
|
|
break;
|
|
}
|
|
|
|
case SERVER_OUTPUT: {
|
|
const session = sessions.get(sessionId) ?? createSession(sessionId);
|
|
|
|
// The callback is the acknowledgement, and payload.length is the byte count the host
|
|
// reserved credit for. Both must be the raw byte length, not a decoded character count.
|
|
session.term.write(payload, () => sendAcknowledgement(sessionId, payload.length));
|
|
break;
|
|
}
|
|
|
|
case SERVER_SESSION_ACTIVATED: {
|
|
const session = sessions.get(sessionId);
|
|
|
|
// Ignored for a pane that does not exist. The host sends this when a tab is selected, and a tab
|
|
// whose session ended still has its pane — but one the host knows about and this page has not
|
|
// created yet cannot be shown, and inventing an empty terminal for it would be worse than waiting
|
|
// for the SessionOpened frame that is already on its way.
|
|
if (!session) {
|
|
break;
|
|
}
|
|
|
|
activate(sessionId);
|
|
|
|
// Refitted on activation, not only on resize. A hidden pane has no layout, so every resize while
|
|
// it was hidden was skipped by the guard in resize() — meaning it comes back holding whatever
|
|
// geometry it had when it was last visible, and the remote pty is still sized to match.
|
|
resize(session, sessionId);
|
|
break;
|
|
}
|
|
|
|
case SERVER_SESSION_REMOVED: {
|
|
const session = sessions.get(sessionId);
|
|
|
|
if (!session) {
|
|
break;
|
|
}
|
|
|
|
/*
|
|
The tab is gone, so the pane goes with it — and this is the only place that is true. A shell that
|
|
ended on its own keeps its pane, because the last thing the remote said is usually why it ended;
|
|
a tab the user closed has nothing left to read.
|
|
|
|
term.dispose() is what actually matters. It releases the WebGL context, and a browser hands out
|
|
about sixteen of those: without this, a day of opening and closing terminals ends with panes that
|
|
cannot get a renderer, and nothing outside this page would ever say why.
|
|
*/
|
|
session.term.dispose();
|
|
session.pane.remove();
|
|
sessions.delete(sessionId);
|
|
|
|
// The notice went with the session record it was held on, but the page can still be pointing at
|
|
// the pane that is now gone. Cleared rather than left dangling, so the banner stops describing a
|
|
// closed tab while the host decides which pane to show next.
|
|
if (activeSessionId === sessionId) {
|
|
activeSessionId = null;
|
|
}
|
|
|
|
renderStatus();
|
|
break;
|
|
}
|
|
|
|
case SERVER_PASTE: {
|
|
const session = sessions.get(sessionId);
|
|
|
|
if (!session || payload.length < 1) {
|
|
break;
|
|
}
|
|
|
|
const execute = payload[0] !== 0;
|
|
const text = new TextDecoder().decode(payload.subarray(1));
|
|
|
|
/*
|
|
term.paste rather than term.input, and that is the whole reason this frame exists rather than
|
|
the host writing the bytes into the pump. paste() wraps the text in bracketed-paste markers
|
|
when the remote has turned that mode on — xterm tracks \e[?2004h from the output stream, which
|
|
is something only this page sees — and a shell that receives a multi-line command inside those
|
|
markers treats every newline as text. Without them it treats each one as "run this", so a
|
|
three-line snippet runs three commands the moment it is inserted.
|
|
|
|
◆ ONE LINE IS TYPED INSTEAD, and this is not an optimisation. Bracketed paste is what readline
|
|
uses to decide it has been pasted into, and bash marks the result as an active region: the
|
|
inserted command sits at the prompt in reverse video, looking selected, until the next
|
|
keystroke clears it. That is right for a paste somebody made with the clipboard and wrong for a
|
|
snippet they picked off the sidebar, which should read as though they had typed it.
|
|
|
|
The markers are only load-bearing for text carrying a newline — that is the whole of what the
|
|
paragraph above protects against — so a single-line snippet does not need them and is written
|
|
as keystrokes. Multi-line still pastes, highlight and all, because "runs three commands
|
|
unasked" is the worse of the two.
|
|
*/
|
|
if (text.includes('\n') || text.includes('\r')) {
|
|
session.term.paste(text);
|
|
} else {
|
|
session.term.input(text);
|
|
}
|
|
|
|
/*
|
|
And the Enter goes through input(), deliberately outside that wrapper. A '\r' appended to the
|
|
pasted text would be bracketed along with it and arrive at the shell as a literal carriage
|
|
return, so nothing would run — which is the failure that looks like the feature working right
|
|
up until somebody wonders why RUN does not.
|
|
*/
|
|
if (execute) {
|
|
session.term.input('\r');
|
|
}
|
|
|
|
/*
|
|
The caret goes back where the text landed. Half of it, anyway: this reaches
|
|
document.activeElement and nothing further, so it is what makes the pane the page's own focused
|
|
element and what stops a hidden textarea from keeping the caret. The other half is Win32
|
|
focus — the sidebar row that sent this frame took it — and only the host can give that back;
|
|
see MainWindowViewModel.TerminalFocusRequested and MainWindow's own FocusTerminalWhenLaidOut.
|
|
*/
|
|
session.term.focus();
|
|
|
|
break;
|
|
}
|
|
|
|
case SERVER_FONT_SIZE: {
|
|
if (payload.length < 1) {
|
|
break;
|
|
}
|
|
|
|
// Applied even with no sessions open, which is the common case at startup: the host sends the
|
|
// stored size as soon as this page attaches, and the first pane is then created at it rather
|
|
// than being created small and resized in front of the user.
|
|
applyFontSize(payload[0]);
|
|
break;
|
|
}
|
|
|
|
case SERVER_SESSION_CLOSED: {
|
|
const session = sessions.get(sessionId);
|
|
const reason = new TextDecoder().decode(payload);
|
|
|
|
if (!session) {
|
|
// No pane, so there is nothing this page can honestly hang the reason on. It used to go into
|
|
// the banner anyway, which printed one session's ending underneath whichever pane happened to
|
|
// be showing at the time.
|
|
break;
|
|
}
|
|
|
|
// The pane and its scrollback stay. The user was probably reading the last thing the
|
|
// remote said, and that is usually why the session ended.
|
|
session.term.write(`\r\n\x1b[38;5;244m── ${reason} ──\x1b[0m\r\n`);
|
|
session.term.options.cursorBlink = false;
|
|
|
|
setSessionNotice(sessionId, reason);
|
|
break;
|
|
}
|
|
|
|
default:
|
|
// A newer host than this page. Ignored rather than fatal.
|
|
break;
|
|
}
|
|
}
|
|
|
|
/*
|
|
── THE SOCKET COMES BACK BY ITSELF, INCLUDING FOR A PAGE NOBODY HAS LOOKED AT IN AN HOUR ────────────
|
|
|
|
Retrying on a timer is the easy half and was the whole of this. The three rules below are what make the
|
|
retry actually reach a page that has been sitting collapsed — each of them a way this page was found
|
|
showing "Reconnecting the terminal view…" over a socket that nothing was reconnecting.
|
|
|
|
1. ONLY THE CURRENT ATTEMPT'S EVENTS COUNT. connect() abandons whatever socket was attached before it,
|
|
and an abandoned socket still reports its end: the host aborts it the moment the newcomer upgrades,
|
|
which is TerminalDataPlane.UpgradeAsync's takeover doing exactly what it is meant to. Counting that
|
|
close as a fresh failure schedules a retry against a socket that has just succeeded, and the
|
|
takeover then aborts *that* one, whose close schedules the next — a loop with no fixed point, in
|
|
which the terminal reconnects every second or so forever and the banner is up for most of it. Every
|
|
handler below asks whether it is still the page's socket before it does anything.
|
|
|
|
2. AN ATTEMPT THAT NEVER FINISHES IS A FAILURE TOO. See HANDSHAKE_TIMEOUT_MS.
|
|
|
|
3. COMING BACK IS A REASON TO TRY, NOT ONLY THE CLOCK. The retry is a setTimeout chain, and a chain is
|
|
precisely 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 at all. 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: coming back shows a terminal that says it is reconnecting and, for as long as that lasts,
|
|
is not.
|
|
|
|
The phone is where this is easiest to reach, because leaving the terminal for another screen
|
|
collapses its WebView to GONE — see createSession's note on the GPU context — and a WebView with no
|
|
surface is a page the platform may treat as hidden. Which of the two mechanisms actually bit is not
|
|
established here, and does not need to be: both end with a timer that will not run, and the fix is
|
|
not to guess at either but to stop depending on the timer alone.
|
|
|
|
Hence the wake-ups at the bottom of this file. What they add is not a faster retry; it is a retry
|
|
driven by the one thing that cannot be throttled, which is the user arriving.
|
|
*/
|
|
|
|
/** @type {number | null} */
|
|
let reconnectTimer = null;
|
|
|
|
/** @type {number | null} */
|
|
let handshakeTimer = null;
|
|
|
|
let reconnectDelay = RECONNECT_INITIAL_DELAY_MS;
|
|
|
|
/**
|
|
* Tries the socket again after a wait, unless a try is already pending.
|
|
*
|
|
* The guard is what keeps 'close' and 'error' from stacking two timers for one failure — a socket
|
|
* that fails to open typically fires both, and each would otherwise schedule its own reconnect.
|
|
*/
|
|
function scheduleReconnect() {
|
|
if (reconnectTimer !== null) {
|
|
return;
|
|
}
|
|
|
|
setTransportStatus('Reconnecting the terminal view…');
|
|
|
|
reconnectTimer = setTimeout(() => {
|
|
reconnectTimer = null;
|
|
connect();
|
|
}, reconnectDelay);
|
|
|
|
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_DELAY_MS);
|
|
}
|
|
|
|
/**
|
|
* Tries the socket again now, if it is down.
|
|
*
|
|
* The entry point for "somebody is looking at this page again", and it has to be safe to call as often
|
|
* as that happens — which on the desktop is every time the window is clicked. A socket that is up makes
|
|
* this nothing at all.
|
|
*
|
|
* An attempt still in CONNECTING is left alone rather than restarted: it may be about to succeed, and
|
|
* the one that is not is already the handshake watchdog's to give up on.
|
|
*/
|
|
function reconnectIfDown() {
|
|
if (socket !== null
|
|
&& (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
|
|
return;
|
|
}
|
|
|
|
// Whatever the timer was going to do, this is doing now. Left pending it would land on top of the
|
|
// socket this call is about to open, and the takeover that followed is the loop rule 1 describes.
|
|
if (reconnectTimer !== null) {
|
|
clearTimeout(reconnectTimer);
|
|
reconnectTimer = null;
|
|
}
|
|
|
|
// Back to the quick attempt. The wait grew to space out retries during an outage nobody was watching,
|
|
// and being called at all means somebody is watching now.
|
|
reconnectDelay = RECONNECT_INITIAL_DELAY_MS;
|
|
|
|
connect();
|
|
}
|
|
|
|
function connect() {
|
|
const token = root.dataset.token;
|
|
const url = root.dataset.socket;
|
|
|
|
/*
|
|
Whatever was attached is abandoned here, and closed rather than dropped: the host takes the socket
|
|
over regardless, but one left open is a connection it has to abort and an event this page then has to
|
|
ignore. Cleared out of `socket` before the close, so that every handler — including that close,
|
|
whenever it lands — can already tell the attempt is no longer the page's.
|
|
*/
|
|
const abandoned = socket;
|
|
socket = null;
|
|
abandoned?.close();
|
|
|
|
// The token travels as a subprotocol rather than a query parameter, which keeps it out of
|
|
// anything that logs URLs.
|
|
const pending = new WebSocket(url, ['dodossh.terminal.v1', `token.${token}`]);
|
|
pending.binaryType = 'arraybuffer';
|
|
socket = pending;
|
|
|
|
/** Whether this attempt is still the page's, rather than one a later connect() has replaced. */
|
|
const isCurrent = () => socket === pending;
|
|
|
|
const forgetHandshakeTimer = () => {
|
|
if (handshakeTimer !== null) {
|
|
clearTimeout(handshakeTimer);
|
|
handshakeTimer = null;
|
|
}
|
|
};
|
|
|
|
forgetHandshakeTimer();
|
|
|
|
handshakeTimer = setTimeout(() => {
|
|
handshakeTimer = null;
|
|
|
|
// Still CONNECTING with nothing on its way. Only the retry is arranged here; abandoning the attempt
|
|
// is left to the connect() that retry runs, which is the one place a socket is replaced.
|
|
if (isCurrent() && pending.readyState === WebSocket.CONNECTING) {
|
|
scheduleReconnect();
|
|
}
|
|
}, HANDSHAKE_TIMEOUT_MS);
|
|
|
|
pending.addEventListener('open', () => {
|
|
// A replaced attempt cannot reach here — connect() closes what it abandons, and a socket closed
|
|
// while connecting never opens — so this is a guard against the ordering rather than a live case.
|
|
if (!isCurrent()) {
|
|
return;
|
|
}
|
|
|
|
forgetHandshakeTimer();
|
|
setTransportStatus('');
|
|
|
|
// Back to the quick attempt for whatever the next failure turns out to be. Kept slow between
|
|
// attempts within one outage, reset once the outage is actually over.
|
|
reconnectDelay = RECONNECT_INITIAL_DELAY_MS;
|
|
});
|
|
|
|
pending.addEventListener('message', (event) => {
|
|
if (isCurrent()) {
|
|
handleFrame(event.data);
|
|
}
|
|
});
|
|
|
|
// Both close and error retry. They are not the same event on every failure — a socket that never
|
|
// opens can fire only 'error', one that opens and later drops fires only 'close' — and the host
|
|
// side of this same problem (TerminalDataPlane.UpgradeAsync's takeover) is exactly why retrying is
|
|
// safe: whichever attempt eventually reaches the host, a fresh valid upgrade always wins the socket.
|
|
const retry = () => {
|
|
if (isCurrent()) {
|
|
forgetHandshakeTimer();
|
|
scheduleReconnect();
|
|
}
|
|
};
|
|
|
|
pending.addEventListener('close', retry);
|
|
pending.addEventListener('error', retry);
|
|
}
|
|
|
|
// One observer for the whole root rather than one per pane: resizes arrive in bursts while a
|
|
// window is being dragged, and a single callback coalesces them for free.
|
|
new ResizeObserver(() => {
|
|
for (const [sessionId, session] of sessions) {
|
|
resize(session, sessionId);
|
|
}
|
|
}).observe(root);
|
|
|
|
window.addEventListener('beforeunload', () => socket?.close());
|
|
|
|
/*
|
|
The ways this page finds out somebody is looking at it again — rule 3 at the top of the transport
|
|
section. All three end in the same check, and that check is what makes them safe to be as noisy as they
|
|
are: with a healthy socket every one of them does nothing.
|
|
|
|
'visibilitychange' is the phone's. A WebView collapsed to GONE is a hidden page, so coming back to the
|
|
terminal screen is the event that says so, and it is the same event Chromium lifts its own throttling
|
|
on — this page simply does not wait to be asked twice.
|
|
|
|
'focus' is the desktop's, where the page is *not* marked hidden while the WebView is collapsed
|
|
(measured; see docs/platform-flags.md) but a window left in the background for hours has had its timers
|
|
throttled all the same. It fires when the terminal takes the keyboard back, which is the same gesture.
|
|
|
|
'online' is neither head's ordinary case, because the socket is loopback and has nothing to do with the
|
|
network — but the event does follow a machine coming back from sleep, and a page with a dead socket has
|
|
no reason to ignore any hint that the world has moved.
|
|
*/
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (!document.hidden) {
|
|
reconnectIfDown();
|
|
}
|
|
});
|
|
|
|
window.addEventListener('focus', reconnectIfDown);
|
|
window.addEventListener('online', reconnectIfDown);
|
|
|
|
connect();
|