Merge branch 'claude/terminal-reattach'
ci / build and test (push) Successful in 2m30s
ci / android head (push) Successful in 3m25s
ci / desktop nightly (push) Successful in 37s
ci / api image (push) Successful in 37s

Brings the Android keep-alive corrections and the terminal renderer
reattach: the foreground service now actually comes up for shells and
an idle Files session, survives refreshes from the background, and the
terminal's data plane lets a reloaded WebView page take its socket back
over instead of freezing every session behind a dead one.
This commit is contained in:
2026-08-09 10:59:01 +02:00
15 changed files with 1100 additions and 93 deletions
+51 -22
View File
@@ -90,32 +90,61 @@ public sealed partial class DodoSshApp : Avalonia.Application
shell.DataContext = viewModel;
// Difference 2: the foreground service, which is what makes TerminalWorkspace's promise — that a
// shell outlives a vault lock — true on a platform that stops backgrounded processes.
//
// The transfer count is real now that the document picker gives this head a way to start one, and
// it is the half that matters most here: a shell survives backgrounding because somebody is looking
// at it, and an upload has to survive precisely when nobody is — the screen is off and the phone is
// in a pocket. Queued counts as active, so putting five files in the queue and locking the phone
// moves five files.
//
// A local rather than a field, matching the desktop head: an Avalonia Application has no disposal
// hook, so a field holding a disposable would have nowhere honest to release it. It stays alive
// because it is subscribed to the workspace, which lives as long as the process.
var keepAlive = new SessionKeepAlive(
workspace,
activeTransfers: () => viewModel.Transfers.ActiveTransfers);
// The other end of the same wire: the workspace announces its own sessions ending, and the queue
// announces transfers appearing and finishing. Without this the notification would come up when an
// upload started and stay up after it finished, which is the failure this class exists to prevent.
viewModel.Transfers.ActivityChanged += (_, _) => keepAlive.Refresh();
keepAlive.Refresh();
// Difference 2, wired up in its own method purely for length — see ComposeKeepAlive for what it
// does and why.
ComposeKeepAlive(workspace, viewModel);
return shell;
}
/// <summary>
/// Wires up the foreground service that makes <c>TerminalWorkspace</c>'s promise — that a shell outlives
/// a vault lock — true on a platform that stops backgrounded processes.
/// </summary>
/// <remarks>
/// <para>
/// Split out of <see cref="Compose"/> for length rather than for reuse; there is exactly one caller.
/// </para>
/// <para>
/// The transfer count is real now that the document picker gives this head a way to start one, and it
/// matters exactly when nobody is looking: a shell survives backgrounding because somebody opened it,
/// and an upload has to survive precisely when nobody is — the screen is off and the phone is in a
/// pocket. Queued counts as active, so putting five files in the queue and locking the phone moves five
/// files. <c>holdsFileSession</c> covers the third case a count alone cannot: a host connected on the
/// Files screen with no transfer moving is still a live SFTP session that backgrounding would sever, and
/// <c>TransfersViewModel.HasLiveFileSession</c> is the existing fact — <c>IsConnected</c> with a real
/// cipher, which a bucket never has — that answers whether one is open.
/// </para>
/// <para>
/// <c>keepAlive</c> is a local rather than a field, matching the desktop head: an Avalonia Application
/// has no disposal hook, so a field holding a disposable would have nowhere honest to release it. It
/// stays alive because it is subscribed to the workspace, which lives as long as the process.
/// </para>
/// </remarks>
private static void ComposeKeepAlive(TerminalWorkspace workspace, MainWindowViewModel viewModel)
{
var keepAlive = new SessionKeepAlive(
workspace,
activeTransfers: () => viewModel.Transfers.ActiveTransfers,
holdsFileSession: () => viewModel.Transfers.HasLiveFileSession);
// The other end of the same wire: the workspace announces its own sessions ending, and the queue
// announces transfers appearing and finishing, and a Files session connecting or disconnecting.
// Without this the notification would come up when an upload started and stay up after it
// finished, which is the failure this class exists to prevent.
viewModel.Transfers.ActivityChanged += (_, _) => keepAlive.Refresh();
// The half that was missing until now: a shell opening. SessionKeepAlive already heard the
// workspace announce a session ending, but nothing announced the opposite — a user who opened a
// shell and backgrounded the app had no foreground service at all, because the only wire in was the
// one for taking it down. TerminalSessionOpened is that other half, forwarded from
// VaultViewModel.SessionOpened, and without this line the service could never come up for a shell
// in the first place, which was precisely the promise this whole arrangement exists to keep.
viewModel.TerminalSessionOpened += (_, _) => keepAlive.Refresh();
keepAlive.Refresh();
}
/// <summary>
/// Writing to this phone's clipboard.
/// </summary>
@@ -6,7 +6,7 @@ using global::Android.OS;
namespace DodoSSH.Client.Android.Platform;
/// <summary>
/// Keeps the process alive for as long as a shell or a transfer is live.
/// Keeps the process alive for as long as a shell, a transfer, or a connected Files session is live.
/// </summary>
/// <remarks>
/// <para>
@@ -41,6 +41,26 @@ internal sealed class SessionForegroundService : Service
private const string ChannelId = "dodossh.sessions";
private const int NotificationId = 1;
// API 33+ requires the request to name a code the RequestPermissionsResult callback would be handed
// back — 1 is fine because this head never implements that callback at all, see
// RequestNotificationPermission's own remark for why a result is not worth listening for.
private const int NotificationPermissionRequestCode = 1;
/// <summary>
/// Whether <see cref="OnStartCommand"/> has run for this process without a matching
/// <see cref="OnDestroy"/> since — i.e. whether Android currently considers this service foregrounded.
/// </summary>
/// <remarks>
/// Volatile because <see cref="Reconcile"/> can run on whatever thread called
/// <see cref="SessionKeepAlive.Refresh"/>, while this is set from the binder thread Android delivers
/// service lifecycle callbacks on — two threads with no other synchronisation between them, and a stale
/// read here is the difference between updating a notification in place and calling
/// <c>StartForegroundService</c> on a service that is already running, which is what defect 3 was.
/// </remarks>
private static volatile bool running;
private static bool notificationPermissionRequested;
/// <remarks>
/// A bound service would tie the sessions' lifetime to a binding, which is the opposite of what is
/// wanted here: the point is that they outlive whatever the user does with the interface.
@@ -49,7 +69,9 @@ internal sealed class SessionForegroundService : Service
public override StartCommandResult OnStartCommand(Intent? intent, StartCommandFlags flags, int startId)
{
StartForeground(NotificationId, BuildNotification(intent?.GetStringExtra("summary") ?? "Working"));
running = true;
StartForeground(NotificationId, BuildNotification(this, intent?.GetStringExtra("summary") ?? "Working"));
// NotSticky: if Android does kill this process, the SSH connections died with it and there is
// nothing to resume. Restarting the service would produce a notification claiming sessions that no
@@ -58,14 +80,36 @@ internal sealed class SessionForegroundService : Service
return StartCommandResult.NotSticky;
}
/// <inheritdoc />
/// <remarks>
/// The other half of <see cref="running"/>. Android calls this whether the service stopped itself or
/// was stopped from outside — <see cref="Reconcile"/>'s down case calls <c>StopService</c> rather than
/// clearing the flag directly, so this override is the one place that actually knows the service has
/// gone, matching how <see cref="OnStartCommand"/> is the one place that knows it has come up.
/// </remarks>
public override void OnDestroy()
{
running = false;
base.OnDestroy();
}
/// <remarks>
/// <para>
/// Low importance on purpose. This notification is a receipt, not an alert — it exists because Android
/// requires one, and because the user is entitled to know the app is holding connections open. Making
/// it buzz would be a notification about nothing having happened.
/// </para>
/// <para>
/// Static, taking the <see cref="Context"/> it needs rather than reading <c>this</c>: the instance path
/// through <see cref="OnStartCommand"/> passes the service itself, and the in-place update path through
/// <see cref="Reconcile"/> has no service instance at all — only <see cref="PhoneEnvironment.Require"/>
/// — because posting to an already-running notification never touches the service's own lifecycle.
/// </para>
/// </remarks>
private Notification BuildNotification(string summary)
private static Notification BuildNotification(Context context, string summary)
{
var manager = (NotificationManager)GetSystemService(NotificationService)!;
var manager = (NotificationManager)context.GetSystemService(Context.NotificationService)!;
if (OperatingSystem.IsAndroidVersionAtLeast(26))
{
@@ -79,12 +123,12 @@ internal sealed class SessionForegroundService : Service
}
var reopen = PendingIntent.GetActivity(
this,
context,
0,
new Intent(this, typeof(MainActivity)).SetFlags(ActivityFlags.SingleTop),
new Intent(context, typeof(MainActivity)).SetFlags(ActivityFlags.SingleTop),
PendingIntentFlags.Immutable | PendingIntentFlags.UpdateCurrent);
return new Notification.Builder(this, ChannelId)
return new Notification.Builder(context, ChannelId)
.SetContentTitle("DodoSSH")
.SetContentText(summary)
.SetSmallIcon(global::Android.Resource.Drawable.IcDialogInfo)
@@ -93,37 +137,138 @@ internal sealed class SessionForegroundService : Service
.Build();
}
/// <summary>Starts or stops the service to match what is actually running.</summary>
/// <summary>Starts, stops, or refreshes the service's notification to match what is actually running.</summary>
/// <param name="liveSessions">Shells with a live channel behind them.</param>
/// <param name="activeTransfers">Transfers still moving bytes.</param>
public static void Reconcile(int liveSessions, int activeTransfers)
/// <param name="holdsFileSession">Whether the Files screen holds a live, idle SFTP connection.</param>
/// <remarks>
/// Three outcomes, not the two a plain start-or-stop would have. Nothing live stops the service, as
/// always. Something live and the service not yet running starts it. Something live and the service
/// already running is the case that used to call <c>StartForegroundService</c> a second time, which on
/// API 31+ throws <c>ForegroundServiceStartNotAllowedException</c> the instant the app is backgrounded
/// — a transfer finishing in the pocket, one of two shells dying — crashing the process and taking the
/// remaining connections with it. That case — running, and the app backgrounded — now only posts a
/// fresh notification through the <see cref="NotificationManager"/> already holding the channel open,
/// which needs no foreground-start permission at all. A foregrounded refresh still prefers a real
/// start even over a service that looks like it is running; the inline remark below is why.
/// </remarks>
public static void Reconcile(int liveSessions, int activeTransfers, bool holdsFileSession)
{
var context = PhoneEnvironment.Require();
var intent = new Intent(context, typeof(SessionForegroundService));
if (liveSessions == 0 && activeTransfers == 0)
if (liveSessions == 0 && activeTransfers == 0 && !holdsFileSession)
{
context.StopService(intent);
context.StopService(new Intent(context, typeof(SessionForegroundService)));
return;
}
// The summary says what is actually held, counted rather than generic — the same principle the
// delete confirmations follow. "DodoSSH is running" would tell the user nothing they could act on.
intent.PutExtra("summary", Summarise(liveSessions, activeTransfers));
var summary = Summarise(liveSessions, activeTransfers, holdsFileSession);
context.StartForegroundService(intent);
// In-place only while backgrounded, where it is the only legal move. Foregrounded, a real start is
// always allowed and is preferred even when `running` says the service is up: a disconnect followed
// by a quick reconnect can land here while the StopService just issued is still in flight, and
// posting to that dying service's notification would leave an orphan receipt over an unprotected
// process — restarting instead makes the flag's small lag harmless. A start on a service that
// really is running only re-delivers OnStartCommand, whose StartForeground updates the same
// notification anyway. CurrentActivity is the foreground signal: set on resume, cleared on pause.
if (running && PhoneEnvironment.CurrentActivity is null)
{
var manager = (NotificationManager)context.GetSystemService(Context.NotificationService)!;
manager.Notify(NotificationId, BuildNotification(context, summary));
return;
}
RequestNotificationPermission(context);
Start(context, summary);
}
private static string Summarise(int liveSessions, int activeTransfers)
/// <remarks>
/// Split out of <see cref="Reconcile"/> for the try/catch alone, which needs its own remark and would
/// otherwise crowd the three-way branch above it.
/// </remarks>
private static void Start(Context context, string summary)
{
var parts = new List<string>(2);
var intent = new Intent(context, typeof(SessionForegroundService));
intent.PutExtra("summary", summary);
try
{
context.StartForegroundService(intent);
}
catch (Java.Lang.IllegalStateException)
{
// ForegroundServiceStartNotAllowedException (API 31+) derives from this, and reaching it here
// means a start-worthy transition — a shell opening, a Files connection completing, the first
// transfer landing in an empty queue — happened while the app was backgrounded, which is
// precisely when Android refuses a new foreground start. There is no retry that helps: by the
// time this catch runs, the moment such a start would have been allowed has already passed.
// Swallowing it is the honest choice and not just the available one — letting the exception
// propagate would crash the process and drop the very shells and transfers this service exists
// to keep alive. A process that keeps running unprotected outlives one that does not run at all.
}
}
/// <summary>
/// Asks for the receipt notification's own permission, the first time this process actually has
/// something to show rather than at launch.
/// </summary>
/// <remarks>
/// At most once per process, via <see cref="notificationPermissionRequested"/> — not to work around a
/// platform limit, since Android already refuses to show the dialogue twice, but because a second call
/// to <c>RequestPermissions</c> after the first is still pending is its own kind of noise. No result is
/// read back: there is nothing this class would do differently for a grant versus a refusal, so a
/// callback would exist only to be empty. What refusal costs is stated rather than hidden — the
/// notification stays invisible — and what it does not cost is the point: the service still starts,
/// still holds the process in the foreground, and the shells and transfers it protects are exactly as
/// safe as if the user had said yes. See the manifest's own comment on this permission.
/// </remarks>
private static void RequestNotificationPermission(Context context)
{
// Isolated as its own guard clause rather than folded into the compound condition below: the
// platform-compatibility analyzer only recognises a version check as guarding what follows when it
// is the sole condition of its own early return, and PostNotifications is annotated API 33+.
if (!OperatingSystem.IsAndroidVersionAtLeast(33))
{
return;
}
// Read into a local rather than referenced again inside the lambda below: the guard clause above
// covers a direct call in this method's own body, but the platform-compatibility analyzer treats a
// lambda as reachable from anywhere and will not extend the guard across that boundary. Capturing
// the already-validated string sidesteps the false positive without weakening the actual check.
var postNotifications = global::Android.Manifest.Permission.PostNotifications;
if (notificationPermissionRequested
|| PhoneEnvironment.CurrentActivity is not { } activity
|| context.CheckSelfPermission(postNotifications) == Permission.Granted)
{
return;
}
notificationPermissionRequested = true;
activity.RunOnUiThread(() =>
activity.RequestPermissions([postNotifications], NotificationPermissionRequestCode));
}
private static string Summarise(int liveSessions, int activeTransfers, bool holdsFileSession)
{
var parts = new List<string>(3);
if (liveSessions > 0)
{
parts.Add(liveSessions == 1 ? "1 shell connected" : $"{liveSessions} shells connected");
}
if (holdsFileSession)
{
parts.Add("Files connected");
}
if (activeTransfers > 0)
{
parts.Add(activeTransfers == 1 ? "1 transfer running" : $"{activeTransfers} transfers running");
@@ -17,22 +17,34 @@ namespace DodoSSH.Client.Android.Platform;
/// tally kept here. It already knows that a session whose shell exited half an hour ago is not live, which
/// a counter incremented on open and decremented on close would not.
/// </para>
/// <para>
/// Three facts feed <see cref="SessionForegroundService.Reconcile"/>, not one: live shells, moving
/// transfers, and an idle-but-connected Files session. The last of those used to be missing entirely —
/// a shell survives backgrounding because somebody opened it, but a Files connection with nothing moving
/// looked, to this class, exactly like nothing being open at all. <c>holdsFileSession</c> below is that
/// gap closed, read the same way the other two facts are: asked, not cached.
/// </para>
/// </remarks>
internal sealed class SessionKeepAlive : IDisposable
{
private readonly TerminalWorkspace workspace;
private readonly Func<int> activeTransfers;
private readonly Func<bool> holdsFileSession;
/// <param name="workspace">The live shells.</param>
/// <param name="activeTransfers">
/// How many transfers are moving bytes. A delegate rather than a queue, because file transfer is out
/// of this head's first scope — see the decision in docs/android-port.md — and this is the seam it
/// will arrive through rather than a dependency taken before there is anything to depend on.
/// How many transfers are moving bytes. A delegate rather than a queue, because ownership of the
/// transfer queue stays with <c>TransfersViewModel</c> — this class only ever asks it a question.
/// </param>
public SessionKeepAlive(TerminalWorkspace workspace, Func<int> activeTransfers)
/// <param name="holdsFileSession">
/// Whether the Files screen holds a live SFTP connection with nothing moving on it — the idle-but-
/// connected case a transfer count alone would miss. See <c>TransfersViewModel.HasLiveFileSession</c>.
/// </param>
public SessionKeepAlive(TerminalWorkspace workspace, Func<int> activeTransfers, Func<bool> holdsFileSession)
{
this.workspace = workspace;
this.activeTransfers = activeTransfers;
this.holdsFileSession = holdsFileSession;
// Raised on whatever thread the pump unwound on, which is fine: starting and stopping a service is
// a binder call and needs no particular thread. Nothing here touches the interface.
@@ -41,13 +53,14 @@ internal sealed class SessionKeepAlive : IDisposable
/// <summary>Re-reads the counts and starts or stops the service to match.</summary>
/// <remarks>
/// Called after anything that could change either count — opening a shell, closing a tab, a transfer
/// finishing. Calling it when nothing changed is free: reconciling to the state it is already in is
/// either a redundant <c>startForegroundService</c> on a running service or a <c>stopService</c> on a
/// stopped one, and Android treats both as no-ops.
/// Called after anything that could change any of the three facts — opening a shell, closing a tab, a
/// transfer finishing, a Files connection opening or closing. Calling it when nothing changed is free:
/// reconciling to the state it is already in is either a redundant <c>startForegroundService</c> — or,
/// now, a redundant notification post — on a running service, or a <c>stopService</c> on a stopped one,
/// and Android treats all of those as no-ops.
/// </remarks>
public void Refresh() =>
SessionForegroundService.Reconcile(workspace.LiveSessionCount, activeTransfers());
SessionForegroundService.Reconcile(workspace.LiveSessionCount, activeTransfers(), holdsFileSession());
/// <inheritdoc />
public void Dispose()
@@ -56,7 +69,7 @@ internal sealed class SessionKeepAlive : IDisposable
// The notification goes with the composition root. Leaving it up over a process that is shutting
// down is how an SSH client acquires a reputation for a notification you cannot get rid of.
SessionForegroundService.Reconcile(0, 0);
SessionForegroundService.Reconcile(0, 0, false);
}
private void OnSessionEnded(object? sender, TerminalSessionEndedEventArgs e) => Refresh();
@@ -12,7 +12,12 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- The service's persistent notification. Runtime-requested on API 33+, and refusal is survivable. -->
<!--
The service's persistent notification. Requested on API 33+ from SessionForegroundService.Reconcile,
the first time in this process there is actually something to show — not at launch, where the ask
would justify nothing on screen yet. Refusal is survivable: the service still starts and still holds
the process in the foreground either way, so a "no" costs the notification and nothing else.
-->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Releases the device key. See AndroidDeviceKeyStore. -->
@@ -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
@@ -620,6 +620,19 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
[ObservableProperty]
private string? connectedCipher;
/// <summary>
/// Whether there is a live SFTP connection this session would lose by dying — the phone's foreground-
/// service question, not the desktop's.
/// </summary>
/// <remarks>
/// <see cref="ConnectedCipher"/> is already the fact that tells a host apart from a bucket, because only
/// a host set it — a bucket is HTTP, per-request, and closes nothing a dying process would have kept
/// open, so it answers false here even while <see cref="IsConnected"/> is true. Android reads this to
/// decide whether an idle Files screen with no transfer moving still needs the process kept alive; the
/// desktop has no such question because nothing stops its process for having gone quiet.
/// </remarks>
internal bool HasLiveFileSession => IsConnected && ConnectedCipher is not null;
/// <summary>The accepted host key's algorithm, e.g. <c>ssh-ed25519</c>. See <see cref="ConnectedCipher"/>.</summary>
[ObservableProperty]
private string? connectedHostKeyAlgorithm;
@@ -745,13 +758,18 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
internal ObservableCollection<TransferRowViewModel> Transfers { get; } = [];
/// <summary>Raised on the UI thread whenever a transfer appears or changes state.</summary>
/// <summary>
/// Raised on the UI thread whenever a transfer appears or changes state, or a host or bucket connects or
/// disconnects.
/// </summary>
/// <remarks>
/// For a head that has to tell the operating system what this process is doing — Android's foreground
/// service, which must be up for as long as bytes are moving and down afterwards. An event rather than
/// letting that head watch <see cref="Transfers"/> itself: the collection announces rows arriving and
/// leaving, and the transition that matters most is neither of those but a row going from RUNNING to
/// DONE without moving.
/// service, which must be up for as long as bytes are moving, or a host session sits open, and down
/// afterwards. An event rather than letting that head watch <see cref="Transfers"/> itself: the
/// collection announces rows arriving and leaving, and the transition that matters most is neither of
/// those but a row going from RUNNING to DONE without moving. Connecting and disconnecting are the other
/// two transitions the service cares about — see <see cref="HasLiveFileSession"/> — and neither touches
/// <see cref="Transfers"/> at all, so they need this same announcement made by hand.
/// </remarks>
internal event EventHandler? ActivityChanged;
@@ -1075,6 +1093,13 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
// far as the remote's own auth.log is concerned, so a log of ours that omitted it would disagree with
// the host's — and anybody comparing the two would be right to believe the host.
connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());
// Raised here rather than from OnIsConnectedChanged, on purpose: IsConnected is set first, above,
// and ConnectedCipher second — a partial method firing off the first assignment would read
// HasLiveFileSession against a ConnectedCipher still holding whatever the previous session left
// there. Only at the end of this method are both facts actually true together.
OnPropertyChanged(nameof(HasLiveFileSession));
ActivityChanged?.Invoke(this, EventArgs.Empty);
}
/// <summary>
@@ -1856,6 +1881,11 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
RemoteTrail.Clear();
SelectedRemoteEntry = null;
// Same ordering reason as the raise at the end of MarkHostConnected: both properties this reads are
// already null above, so the raise belongs after them rather than in OnIsConnectedChanged. This also
// covers OpenBucketAsync, which calls this method first and never itself turns HasLiveFileSession on.
OnPropertyChanged(nameof(HasLiveFileSession));
ActivityChanged?.Invoke(this, EventArgs.Empty);
}
/// <remarks>
+94 -11
View File
@@ -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
@@ -66,7 +66,6 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
new(TaskCreationOptions.RunContinuationsAsynchronously);
private WebSocket? socket;
private int accepted;
private int disposed;
/// <param name="assets">Where the renderer's files come from.</param>
@@ -106,6 +105,26 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
/// </remarks>
public event EventHandler<TerminalFontSizeStepEventArgs>? FontSizeStepRequested;
/// <summary>
/// Raised after a socket attaches — the first one, and every later takeover.
/// </summary>
/// <remarks>
/// <para>
/// Raised after <see cref="socket"/> has been swapped in but before <see cref="ReceiveLoopAsync"/> starts
/// consuming it, on the socket-accept thread — the same thread that is in the middle of
/// <see cref="UpgradeAsync"/> for this connection. <see cref="TerminalWorkspace"/> is this event's one
/// subscriber, and it uses the ordering to replay session state before anything the fresh page sends
/// (a resize, an early acknowledgement) can be dispatched; see its remark for why the two racing is
/// harmless regardless.
/// </para>
/// <para>
/// Unlike <see cref="RendererAttached"/>, which resolves once and answers "has a renderer ever attached"
/// for <see cref="TerminalWorkspace.WaitForRendererAsync"/>, this fires every time — because a takeover
/// is exactly the case <see cref="RendererAttached"/> was never meant to describe again.
/// </para>
/// </remarks>
public event EventHandler? SocketAttached;
/// <summary>Registers a session so inbound frames can be routed to it.</summary>
public void Register(uint sessionId, TerminalSessionPump pump)
{
@@ -148,8 +167,9 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
// Each connection on its own task, and deliberately not awaited. An upgraded WebSocket
// lives for the whole session, so handling connections in sequence would leave the accept
// loop parked inside the receive loop and every later request unanswered — the page's
// script and stylesheet among them. Concurrency needs no coordination here because the
// single-attach guard is an interlocked exchange.
// script and stylesheet among them. Two upgrades racing each other need no coordination
// here either, because the takeover in UpgradeAsync swaps the shared socket field with an
// interlocked exchange rather than assuming it is the only writer.
_ = HandleConnectionAsync(client, linked.Token);
}
}
@@ -192,6 +212,29 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
.SendAsync(frame, WebSocketMessageType.Binary, endOfMessage: true, cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// Not a cancellation despite the type: .NET's ManagedWebSocket wraps a send that fails because
// the underlying connection is already gone — which is exactly what a killed renderer's socket
// looks like — in an OperationCanceledException of its own manufacture, regardless of whether
// anyone actually cancelled anything. The filter is what tells the two apart: if the caller's
// own token were the cause, IsCancellationRequested would be true here and this catch does not
// apply, so a real cancellation still propagates. Everything below about why this must not
// fault the caller applies here exactly as it does to the exception types in the next catch.
}
catch (Exception exception)
when (exception is WebSocketException or ObjectDisposedException
or InvalidOperationException or IOException)
{
// The state check above is not atomic with the send, and a WebView renderer process killed by
// Android leaves its socket reporting Open long after nobody is reading from the other end. This
// has to read as "nobody listening" — the same as the no-socket case above — and never as a
// fault: SendAsync is called from TerminalSessionPump.SendOutputAsync inside the flush loop, and
// letting this exception escape would fault that loop. A faulted flush loop stops draining the
// credit window, the reader blocks once it fills, and the SSH session behind it freezes for good
// while LiveSessionCount still counts it as running. A dropped frame is recoverable — a frozen
// session is not.
}
finally
{
sendGate.Release();
@@ -267,6 +310,25 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
.ConfigureAwait(false);
}
/// <remarks>
/// <para>
/// <b>Takeover, not rejection.</b> A valid upgrade always wins the socket, even when one is already
/// attached — the old socket is aborted and the newcomer takes its place. Refusing a second attach used
/// to be the rule, on the theory that one renderer lives for the whole process. That is WebView2's
/// truth and not Android's: the platform kills the WebView's renderer process under memory pressure or
/// simply for being backgrounded, the page reloads, and the reload's socket is a second valid upgrade —
/// refusing it left the terminal permanently unreachable with no way back short of restarting the app.
/// </para>
/// <para>
/// Refusing protects nothing here anyway: only our own page knows the token (see the type-level remark
/// on what the token defends against), so a second valid upgrade <em>is</em> our page, reattaching.
/// Waiting for the old socket to notice it is dead and close on its own is not a safer alternative
/// either — a killed renderer process sends no TCP FIN, so the old receive loop can sit unaware for the
/// whole 30-second keepalive interval, and every reload landing in that window would still find the
/// door held shut by a socket nobody is on the other end of. Taking over immediately is what makes a
/// reload actually reattach.
/// </para>
/// </remarks>
private async Task UpgradeAsync(
Stream stream,
HttpRequestLine request,
@@ -280,16 +342,6 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
return;
}
if (Interlocked.Exchange(ref accepted, 1) == 1)
{
// One renderer, one socket. A second attach would be either a bug or something else on the
// machine having found the port.
await WriteResponseAsync(
stream, "409 Conflict", "text/plain", "Already attached"u8.ToArray(), cancellationToken)
.ConfigureAwait(false);
return;
}
var key = request.Headers.GetValueOrDefault("sec-websocket-key")!;
var accept = ComputeHandshakeAccept(key);
@@ -314,10 +366,28 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
KeepAliveInterval = TimeSpan.FromSeconds(30),
});
socket = webSocket;
rendererAttached.TrySetResult();
// Whatever was attached before is displaced, not merely overwritten: Exchange hands back the old
// reference so it can be aborted rather than left to linger as a socket nothing reads from again.
// Abort rather than a graceful close — a close frame would wait on a peer that, per the remark
// above, may never notice it should reply, and the newcomer already proved it is our page.
var previous = Interlocked.Exchange(ref socket, webSocket);
previous?.Abort();
await ReceiveLoopAsync(webSocket, cancellationToken).ConfigureAwait(false);
rendererAttached.TrySetResult();
SocketAttached?.Invoke(this, EventArgs.Empty);
try
{
await ReceiveLoopAsync(webSocket, cancellationToken).ConfigureAwait(false);
}
finally
{
// Cleared only if the field still holds this connection's own socket. A takeover has already
// swapped in a newer one by the time an aborted receive loop unwinds to here, and clearing the
// field regardless would race the newcomer: whichever of the two finished last would win, and
// it must always be the newcomer, never this one going away.
Interlocked.CompareExchange(ref socket, null, webSocket);
}
}
/// <remarks>
@@ -101,6 +101,13 @@ public sealed class TerminalWorkspace : IAsyncDisposable
private readonly Lock sessionGate = new();
private readonly CancellationTokenSource lifetime = new();
/// <summary>
/// The payload that marks a <see cref="TerminalServerOpcode.SessionOpened"/> frame as a replay rather
/// than a fresh open. A one-byte non-empty payload, so terminal.js's existing length check (empty
/// payload for a real open) tells the two apart without a second opcode.
/// </summary>
private static readonly byte[] ReplayMarker = [1];
private uint nextSessionId = 1;
private Task? server;
private int disposed;
@@ -125,6 +132,12 @@ public sealed class TerminalWorkspace : IAsyncDisposable
// came from. Nothing here decides anything about the size: the shell owns it, because the shell is
// what remembers it between launches.
dataPlane.FontSizeStepRequested += (_, e) => FontSizeStepRequested?.Invoke(this, e);
// Fire-and-forget: this fires on the socket-accept thread, in the middle of the data plane's own
// handshake handling, and has no business making that wait on however long a replay takes. See
// ReplayAfterAttachAsync for what "replay" means and why racing the fresh page's own first frames
// is harmless.
dataPlane.SocketAttached += (_, _) => _ = ReplayAfterAttachAsync();
}
/// <summary>
@@ -223,6 +236,26 @@ public sealed class TerminalWorkspace : IAsyncDisposable
}
}
/// <summary>
/// A live session's flow-control window, or null when the id names no session this workspace still has
/// open.
/// </summary>
/// <remarks>
/// A test seam rather than something the shell has ever needed: nothing outside this assembly has a
/// reason to see a pump's credit window rather than what the transport does with it, but
/// <see cref="ReplayAfterAttachAsync"/>'s reset of that window on reattach is exactly the kind of thing
/// that is easy to get backwards, and worth asserting directly rather than only through its side
/// effects. Internal rather than public, reachable from the test assembly through the
/// <c>InternalsVisibleTo</c> this project already declares for it.
/// </remarks>
internal CreditWindow? CreditsFor(uint sessionId)
{
lock (sessionGate)
{
return sessions.TryGetValue(sessionId, out var session) ? session.Pump.Credits : null;
}
}
/// <summary>
/// Raised with the session id when a shell ends on its own.
/// </summary>
@@ -254,6 +287,24 @@ public sealed class TerminalWorkspace : IAsyncDisposable
/// </remarks>
public event EventHandler<TerminalFontSizeStepEventArgs>? FontSizeStepRequested;
/// <summary>
/// Raised once a (re)attached renderer has been sent everything this workspace owns for it.
/// </summary>
/// <remarks>
/// <para>
/// The workspace's own share of "put the page back the way it was" is the sessions — each live one gets
/// its <c>SessionOpened</c> frame again, done by the time this fires. What is left is what the workspace
/// has no business owning: the font size and which tab is selected are both remembered by the shell, not
/// by a terminal, so this is the seam the shell uses to re-push them. See
/// <c>MainWindowViewModel</c>'s subscription for the other half.
/// </para>
/// <para>
/// Raised on the socket-accept thread, same as <see cref="TerminalDataPlane.SocketAttached"/> that
/// triggers it — a handler that touches a view model has to marshal.
/// </para>
/// </remarks>
public event EventHandler? RendererReattached;
/// <summary>Starts the loopback listener.</summary>
public void Start() => server = dataPlane.RunAsync(lifetime.Token);
@@ -563,6 +614,71 @@ public sealed class TerminalWorkspace : IAsyncDisposable
lifetime.Dispose();
}
/// <summary>
/// Rebuilds a freshly (re)attached page's idea of what is running, then tells the shell to rebuild its
/// own.
/// </summary>
/// <remarks>
/// <para>
/// Runs on the socket-accept thread that raised <see cref="TerminalDataPlane.SocketAttached"/> — the
/// constructor wires it up fire-and-forget for exactly that reason, so this method owns its own error
/// handling rather than leaving an unobserved exception for nobody to see.
/// </para>
/// <para>
/// Every live session — one whose <c>Run</c> has not completed — gets two things. Its credit window is
/// reset, because whatever was outstanding was reserved against bytes sent to a page that is now gone;
/// the acknowledgement that would return that credit died with it, and without this reset the session
/// would stall the moment 256 KiB of history had accumulated. And it gets its <c>SessionOpened</c> frame
/// again, marked with <see cref="ReplayMarker"/> so the page can tell a reattach from a session that is
/// genuinely new — the same frame a page that survived the socket drop already has a pane for, and one a
/// reloaded page does not.
/// </para>
/// <para>
/// A session whose shell has already ended gets nothing here. Its scrollback lived only in the page that
/// is gone, and sending a frame that implied otherwise would be exactly the kind of dishonesty this
/// fix is supposed to remove, not add. The tab strip still shows that session ended; nothing about this
/// method changes what <see cref="LiveSessionCount"/> or <see cref="IsSessionLive"/> report.
/// </para>
/// <para>
/// This can race the fresh page's own first frames — an early resize, an acknowledgement for output it
/// already had. That is harmless: every frame in both directions names its session, delivery order
/// within a session is preserved by both xterm and the socket, and a frame for a pane the page has not
/// created yet is simply dropped, the same as any frame for a session it does not know — see
/// <c>terminal.js</c>'s <c>handleFrame</c>.
/// </para>
/// </remarks>
private async Task ReplayAfterAttachAsync()
{
KeyValuePair<uint, LiveSession>[] live;
lock (sessionGate)
{
live = [.. sessions.Where(entry => !entry.Value.Run.IsCompleted)];
}
try
{
foreach (var (sessionId, session) in live)
{
session.Pump.Credits.Reset();
await dataPlane
.SendAsync(
TerminalFrame.Create((byte)TerminalServerOpcode.SessionOpened, sessionId, ReplayMarker),
CancellationToken.None)
.ConfigureAwait(false);
}
RendererReattached?.Invoke(this, EventArgs.Empty);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// Best-effort, same as every other fire-and-forget path here: a page that dies again mid-replay
// leaves nothing worse than the problem this method exists to fix, and there is no caller on
// this thread left to hand a failure to.
}
}
private async Task RunSessionAsync(uint sessionId, TerminalSessionPump pump)
{
try