Public Access
Teach the page and the shell to put a reattached view back together
The page's socket now retries itself forever with backoff — a dropped socket is an ordinary event on a phone, not the end of the terminal's life — and createSession is idempotent, so a replay landing on a pane that survived changes nothing. A replay creating a pane that did not survive writes one dim line saying the earlier output stayed on the host, because that is the truth about a reloaded page's scrollback. The shell answers RendererReattached with the two things only it owns: the font size, and which tab is active.
This commit is contained in:
@@ -525,6 +525,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
// list. Detached in DisposeAsync, which is the only point either of them ends.
|
||||
this.workspace.SessionEnded += OnWorkspaceSessionEnded;
|
||||
this.workspace.FontSizeStepRequested += OnFontSizeStepRequested;
|
||||
this.workspace.RendererReattached += OnRendererReattached;
|
||||
|
||||
settings = new ClientSettingsStore(paths);
|
||||
|
||||
@@ -635,6 +636,31 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
private void OnFontSizeStepRequested(object? sender, TerminalFontSizeStepEventArgs e) =>
|
||||
Dispatcher.UIThread.Post(() => StepTerminalFontSize(e.Step));
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Marshalled for the same reason as <see cref="OnFontSizeStepRequested"/>: this arrives on the data
|
||||
/// plane's socket-accept thread, and both properties it reads here — <see cref="TerminalFontSize"/> and
|
||||
/// <see cref="SelectedTab"/> — are bound to by the interface.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="TerminalWorkspace.RendererReattached"/> fires once the workspace has replayed what it
|
||||
/// owns — the live sessions. Font size and the choice of active tab are not the workspace's to know;
|
||||
/// they live here, so this is the other half of putting a reattached page back the way it was. The size
|
||||
/// is sent exactly as <see cref="TellRendererTheFontSizeAsync"/> sends it at startup, because nothing
|
||||
/// has changed — the page has merely forgotten, and this is only a reminder.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnRendererReattached(object? sender, EventArgs e) =>
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
_ = workspace.SetFontSizeAsync(TerminalFontSize, CancellationToken.None).AsTask();
|
||||
|
||||
if (SelectedTab is { } tab)
|
||||
{
|
||||
_ = workspace.ActivateSessionAsync(tab.SessionId, CancellationToken.None).AsTask();
|
||||
}
|
||||
});
|
||||
|
||||
[ObservableProperty]
|
||||
private ShellState state = ShellState.Starting;
|
||||
|
||||
@@ -3051,6 +3077,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
workspace.SessionEnded -= OnWorkspaceSessionEnded;
|
||||
workspace.FontSizeStepRequested -= OnFontSizeStepRequested;
|
||||
workspace.RendererReattached -= OnRendererReattached;
|
||||
transfers.PropertyChanged -= OnTransfersPropertyChanged;
|
||||
|
||||
// Stopped here rather than left to the process exiting with it: the loop holds no vault key and
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
/*
|
||||
The renderer half of the terminal data plane.
|
||||
|
||||
Two things here are load-bearing and easy to get wrong:
|
||||
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
|
||||
@@ -15,6 +15,14 @@
|
||||
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. 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;
|
||||
@@ -33,6 +41,25 @@ 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;
|
||||
|
||||
/*
|
||||
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
|
||||
@@ -192,7 +219,19 @@ function handleKey(event) {
|
||||
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);
|
||||
@@ -290,10 +329,23 @@ function handleFrame(buffer) {
|
||||
const payload = new Uint8Array(buffer, HEADER_LENGTH);
|
||||
|
||||
switch (opcode) {
|
||||
case SERVER_SESSION_OPENED:
|
||||
createSession(sessionId);
|
||||
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);
|
||||
}
|
||||
|
||||
setStatus('');
|
||||
break;
|
||||
}
|
||||
|
||||
case SERVER_OUTPUT: {
|
||||
const session = sessions.get(sessionId) ?? createSession(sessionId);
|
||||
@@ -414,6 +466,31 @@ function handleFrame(buffer) {
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {number | null} */
|
||||
let reconnectTimer = 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;
|
||||
}
|
||||
|
||||
setStatus('Reconnecting the terminal view…');
|
||||
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connect();
|
||||
}, reconnectDelay);
|
||||
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_DELAY_MS);
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const token = root.dataset.token;
|
||||
const url = root.dataset.socket;
|
||||
@@ -423,16 +500,22 @@ function connect() {
|
||||
socket = new WebSocket(url, ['dodossh.terminal.v1', `token.${token}`]);
|
||||
socket.binaryType = 'arraybuffer';
|
||||
|
||||
socket.addEventListener('open', () => setStatus(''));
|
||||
socket.addEventListener('open', () => {
|
||||
setStatus('');
|
||||
|
||||
// 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;
|
||||
});
|
||||
|
||||
socket.addEventListener('message', (event) => handleFrame(event.data));
|
||||
|
||||
socket.addEventListener('close', () => {
|
||||
setStatus('Disconnected from DodoSSH.');
|
||||
});
|
||||
|
||||
socket.addEventListener('error', () => {
|
||||
setStatus('The terminal connection failed.');
|
||||
});
|
||||
// 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.
|
||||
socket.addEventListener('close', scheduleReconnect);
|
||||
socket.addEventListener('error', scheduleReconnect);
|
||||
}
|
||||
|
||||
// One observer for the whole root rather than one per pane: resizes arrive in bursts while a
|
||||
|
||||
Reference in New Issue
Block a user