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.
|
// list. Detached in DisposeAsync, which is the only point either of them ends.
|
||||||
this.workspace.SessionEnded += OnWorkspaceSessionEnded;
|
this.workspace.SessionEnded += OnWorkspaceSessionEnded;
|
||||||
this.workspace.FontSizeStepRequested += OnFontSizeStepRequested;
|
this.workspace.FontSizeStepRequested += OnFontSizeStepRequested;
|
||||||
|
this.workspace.RendererReattached += OnRendererReattached;
|
||||||
|
|
||||||
settings = new ClientSettingsStore(paths);
|
settings = new ClientSettingsStore(paths);
|
||||||
|
|
||||||
@@ -635,6 +636,31 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
private void OnFontSizeStepRequested(object? sender, TerminalFontSizeStepEventArgs e) =>
|
private void OnFontSizeStepRequested(object? sender, TerminalFontSizeStepEventArgs e) =>
|
||||||
Dispatcher.UIThread.Post(() => StepTerminalFontSize(e.Step));
|
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]
|
[ObservableProperty]
|
||||||
private ShellState state = ShellState.Starting;
|
private ShellState state = ShellState.Starting;
|
||||||
|
|
||||||
@@ -3051,6 +3077,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
|
|
||||||
workspace.SessionEnded -= OnWorkspaceSessionEnded;
|
workspace.SessionEnded -= OnWorkspaceSessionEnded;
|
||||||
workspace.FontSizeStepRequested -= OnFontSizeStepRequested;
|
workspace.FontSizeStepRequested -= OnFontSizeStepRequested;
|
||||||
|
workspace.RendererReattached -= OnRendererReattached;
|
||||||
transfers.PropertyChanged -= OnTransfersPropertyChanged;
|
transfers.PropertyChanged -= OnTransfersPropertyChanged;
|
||||||
|
|
||||||
// Stopped here rather than left to the process exiting with it: the loop holds no vault key and
|
// 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.
|
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
|
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
|
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
|
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
|
happened to straddle a frame boundary, which shows up as occasional mojibake in exactly the
|
||||||
conditions that are hardest to reproduce.
|
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;
|
const SERVER_OUTPUT = 1;
|
||||||
@@ -33,6 +41,25 @@ const CLIENT_FONT_SIZE_STEP = 4;
|
|||||||
const HEADER_LENGTH = 5;
|
const HEADER_LENGTH = 5;
|
||||||
const SCROLLBACK_LINES = 5000;
|
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 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
|
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;
|
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) {
|
function createSession(sessionId) {
|
||||||
|
const existing = sessions.get(sessionId);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
const pane = document.createElement('div');
|
const pane = document.createElement('div');
|
||||||
pane.className = 'pane';
|
pane.className = 'pane';
|
||||||
pane.dataset.sessionId = String(sessionId);
|
pane.dataset.sessionId = String(sessionId);
|
||||||
@@ -290,10 +329,23 @@ function handleFrame(buffer) {
|
|||||||
const payload = new Uint8Array(buffer, HEADER_LENGTH);
|
const payload = new Uint8Array(buffer, HEADER_LENGTH);
|
||||||
|
|
||||||
switch (opcode) {
|
switch (opcode) {
|
||||||
case SERVER_SESSION_OPENED:
|
case SERVER_SESSION_OPENED: {
|
||||||
createSession(sessionId);
|
// 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('');
|
setStatus('');
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case SERVER_OUTPUT: {
|
case SERVER_OUTPUT: {
|
||||||
const session = sessions.get(sessionId) ?? createSession(sessionId);
|
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() {
|
function connect() {
|
||||||
const token = root.dataset.token;
|
const token = root.dataset.token;
|
||||||
const url = root.dataset.socket;
|
const url = root.dataset.socket;
|
||||||
@@ -423,16 +500,22 @@ function connect() {
|
|||||||
socket = new WebSocket(url, ['dodossh.terminal.v1', `token.${token}`]);
|
socket = new WebSocket(url, ['dodossh.terminal.v1', `token.${token}`]);
|
||||||
socket.binaryType = 'arraybuffer';
|
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('message', (event) => handleFrame(event.data));
|
||||||
|
|
||||||
socket.addEventListener('close', () => {
|
// Both close and error retry. They are not the same event on every failure — a socket that never
|
||||||
setStatus('Disconnected from DodoSSH.');
|
// 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('error', () => {
|
socket.addEventListener('close', scheduleReconnect);
|
||||||
setStatus('The terminal connection failed.');
|
socket.addEventListener('error', scheduleReconnect);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// One observer for the whole root rather than one per pane: resizes arrive in bursts while a
|
// One observer for the whole root rather than one per pane: resizes arrive in bursts while a
|
||||||
|
|||||||
Reference in New Issue
Block a user