diff --git a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
index e239a80..00073ef 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
@@ -11247,10 +11247,7 @@ internal sealed partial class VaultViewModel(
}
catch (TimeoutException)
{
- Abandon(
- attempt,
- "The terminal did not start, so nothing was connected. The Microsoft Edge WebView2 "
- + "runtime is probably missing or blocked; install it and try again.");
+ Abandon(attempt, RendererNeverStarted);
}
catch (SshHostKeyUnknownException exception)
{
@@ -11275,6 +11272,29 @@ internal sealed partial class VaultViewModel(
}
}
+ /// What a renderer that never attached is reported as.
+ ///
+ ///
+ /// The wait is translated rather than reported for the reason gives —
+ /// says only "The operation has timed out" — and the whole value of the
+ /// translation is naming where to look. Which is why it cannot be one sentence: the desktop's answer is
+ /// a runtime this application does not install, and the phone has no such runtime and no such answer.
+ /// Telling somebody on a handset to install Microsoft Edge WebView2 is worse than saying nothing, at the
+ /// one moment they are trying to work out what went wrong.
+ ///
+ ///
+ /// A runtime check rather than a constructor parameter, for the reason
+ /// MainWindowViewModel.GestureWait records at length: which renderer is behind the terminal is a
+ /// fact about the platform this assembly is running on, not about one installation of it.
+ ///
+ ///
+ private static string RendererNeverStarted =>
+ OperatingSystem.IsAndroid()
+ ? "The terminal did not start, so nothing was connected. Android's WebView is probably "
+ + "disabled or updating; check it in Settings and try again."
+ : "The terminal did not start, so nothing was connected. The Microsoft Edge WebView2 "
+ + "runtime is probably missing or blocked; install it and try again.";
+
/// Says, in one place, that an attempt ended without a session and why.
///
/// The reason goes to two places on purpose. The status line is where somebody watching this screen is
diff --git a/src/DodoSSH.Client.Shell/WebAssets/terminal.js b/src/DodoSSH.Client.Shell/WebAssets/terminal.js
index 1d7f8b1..68298ee 100644
--- a/src/DodoSSH.Client.Shell/WebAssets/terminal.js
+++ b/src/DodoSSH.Client.Shell/WebAssets/terminal.js
@@ -255,8 +255,31 @@ function createSession(sessionId) {
// 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 {
- term.loadAddon(new WebglAddon.WebglAddon());
+ 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);
}
@@ -296,10 +319,17 @@ function activate(sessionId) {
// 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, which an earlier version of this comment claimed.
-// Collapsing the host's WebView 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.
+// 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) {