diff --git a/src/SharpClient.App/Platforms/Android/ConnectionKeepAliveService.cs b/src/SharpClient.App/Platforms/Android/ConnectionKeepAliveService.cs
index 56b009a..1d0f533 100644
--- a/src/SharpClient.App/Platforms/Android/ConnectionKeepAliveService.cs
+++ b/src/SharpClient.App/Platforms/Android/ConnectionKeepAliveService.cs
@@ -37,6 +37,8 @@ internal sealed class ConnectionKeepAliveService : Service
private PowerManager.WakeLock? _wakeLock;
private ConnectivityManager? _connectivity;
private ConnectivityManager.NetworkCallback? _networkCallback;
+ private Network? _boundNetwork;
+ private bool _haveSeenNetwork;
// ── Service lifecycle ─────────────────────────────────────────────────────
@@ -101,9 +103,11 @@ private void ReleaseWakeLock()
// ── Connectivity monitoring ───────────────────────────────────────────────
//
- // Telnet reconnection itself is owned by the Core layer's auto-reconnect; this callback only
- // refreshes the persistent notification when connectivity returns so the user sees the service
- // is healthy and the process stays alive across network transitions.
+ // Watches the device's DEFAULT network. When it changes on the move (WiFi↔cellular, tower
+ // handoff, coming out of a dead zone) the old sockets are silently dead. We (a) pin the process
+ // to the new network so reconnects use the live interface at once, and (b) raise a signal that
+ // the coordinator turns into an immediate ForceReconnect on every session — instead of waiting
+ // out the Core backoff / keepalive.
private void RegisterNetworkCallback()
{
@@ -119,10 +123,9 @@ private void RegisterNetworkCallback()
}
_networkCallback = new KeepAliveNetworkCallback(this);
- var request = new NetworkRequest.Builder()
- .AddCapability(NetCapability.Internet)!
- .Build();
- _connectivity.RegisterNetworkCallback(request!, _networkCallback);
+ // Default-network callback (API 24+): fires OnAvailable when the default network changes and
+ // OnLost when it drops — exactly the transitions that kill a moving connection.
+ _connectivity.RegisterDefaultNetworkCallback(_networkCallback);
}
private void UnregisterNetworkCallback()
@@ -139,17 +142,47 @@ private void UnregisterNetworkCallback()
}
}
+ // Release any network binding so the process isn't pinned once we stop.
+ if (_boundNetwork is not null)
+ {
+ try { _connectivity?.BindProcessToNetwork(null); } catch (Java.Lang.Exception) { }
+ _boundNetwork = null;
+ }
+ _haveSeenNetwork = false;
+
_networkCallback?.Dispose();
_networkCallback = null;
_connectivity = null;
}
- private void OnNetworkAvailable()
+ private void OnNetworkAvailable(Network network)
{
+ // Pin the process to the new default network so reconnects use the live interface immediately.
+ try { _connectivity?.BindProcessToNetwork(network); } catch (Java.Lang.Exception) { }
+ _boundNetwork = network;
+
+ // The first callback is the network we're already connected on (service just started) — don't
+ // churn. Any later one is a genuine change/return, so kick every session to reconnect now.
+ if (_haveSeenNetwork)
+ {
+ Services.NetworkChangeSignal.RaiseChanged();
+ }
+ _haveSeenNetwork = true;
+
var nm = (NotificationManager?)GetSystemService(NotificationService);
nm?.Notify(NotificationId, BuildNotification("Connected"));
}
+ private void OnNetworkLost(Network network)
+ {
+ // Drop the binding to the dead network so a new default can be used once it appears.
+ if (_boundNetwork is not null)
+ {
+ try { _connectivity?.BindProcessToNetwork(null); } catch (Java.Lang.Exception) { }
+ _boundNetwork = null;
+ }
+ }
+
// ── Helpers ───────────────────────────────────────────────────────────────
private void EnsureNotificationChannel()
@@ -173,6 +206,7 @@ private Notification BuildNotification(string statusText) =>
private sealed class KeepAliveNetworkCallback(ConnectionKeepAliveService service)
: ConnectivityManager.NetworkCallback
{
- public override void OnAvailable(Network network) => service.OnNetworkAvailable();
+ public override void OnAvailable(Network network) => service.OnNetworkAvailable(network);
+ public override void OnLost(Network network) => service.OnNetworkLost(network);
}
}
diff --git a/src/SharpClient.App/Services/ConnectionKeepAliveCoordinator.cs b/src/SharpClient.App/Services/ConnectionKeepAliveCoordinator.cs
index 46cacdf..2d7fc5d 100644
--- a/src/SharpClient.App/Services/ConnectionKeepAliveCoordinator.cs
+++ b/src/SharpClient.App/Services/ConnectionKeepAliveCoordinator.cs
@@ -22,9 +22,21 @@ public ConnectionKeepAliveCoordinator(ISessionManager sessions, IConnectionKeepA
_sessions = sessions;
_keepAlive = keepAlive;
_sessions.Changed += OnSessionsChanged;
+ NetworkChangeSignal.Changed += OnNetworkChanged;
OnSessionsChanged();
}
+ // The device's default network changed (raised on Android by the keep-alive service). Every
+ // session's socket is now dead, so force each one to reconnect immediately rather than waiting
+ // out the Core backoff. ForceReconnectAsync is a no-op for intentionally-disconnected sessions.
+ private void OnNetworkChanged()
+ {
+ foreach (var session in _sessions.Sessions)
+ {
+ _ = session.ForceReconnectAsync();
+ }
+ }
+
private void OnSessionsChanged()
{
lock (_gate)
@@ -78,6 +90,7 @@ private void Evaluate()
public void Dispose()
{
+ NetworkChangeSignal.Changed -= OnNetworkChanged;
lock (_gate)
{
_sessions.Changed -= OnSessionsChanged;
diff --git a/src/SharpClient.App/Services/NetworkChangeSignal.cs b/src/SharpClient.App/Services/NetworkChangeSignal.cs
new file mode 100644
index 0000000..3805dcf
--- /dev/null
+++ b/src/SharpClient.App/Services/NetworkChangeSignal.cs
@@ -0,0 +1,17 @@
+namespace SharpClient.App.Services;
+
+///
+/// Process-wide bridge for "the device's default network changed" events. The Android
+/// keep-alive service (which owns the connectivity callback but has no session references)
+/// raises it; (which has the sessions but is
+/// platform-agnostic) listens and forces a reconnect. Keeping it here — not in the Android
+/// service type — lets the coordinator subscribe without referencing any Android-only API.
+/// On platforms that never raise it, subscribers simply never fire.
+///
+internal static class NetworkChangeSignal
+{
+ /// Raised when the active/default network changes (e.g. WiFi↔cellular, tower handoff).
+ public static event Action? Changed;
+
+ public static void RaiseChanged() => Changed?.Invoke();
+}
diff --git a/src/SharpClient.Core/Connection/ITelnetConnection.cs b/src/SharpClient.Core/Connection/ITelnetConnection.cs
index 65cfd5e..ac56a7b 100644
--- a/src/SharpClient.Core/Connection/ITelnetConnection.cs
+++ b/src/SharpClient.Core/Connection/ITelnetConnection.cs
@@ -17,6 +17,13 @@ public interface ITelnetConnection : IAsyncDisposable
public Task ConnectAsync(string host, int port, CancellationToken cancellationToken = default);
+ ///
+ /// Immediately (re)connects to the last endpoint, dropping the current socket and any reconnect
+ /// backoff. Used to recover fast when an external signal (e.g. an Android network-change
+ /// callback) indicates connectivity returned. No-op after an intentional disconnect.
+ ///
+ public Task ForceReconnectAsync();
+
public Task SendAsync(string line);
public Task DisconnectAsync();
diff --git a/src/SharpClient.Core/Connection/ReconnectOptions.cs b/src/SharpClient.Core/Connection/ReconnectOptions.cs
index f50c637..dd17b21 100644
--- a/src/SharpClient.Core/Connection/ReconnectOptions.cs
+++ b/src/SharpClient.Core/Connection/ReconnectOptions.cs
@@ -8,8 +8,14 @@ namespace SharpClient.Core.Connection;
///
public sealed record ReconnectOptions
{
- /// Maximum number of reconnect attempts before giving up with .
- public int MaxAttempts { get; init; } = 6;
+ ///
+ /// Maximum number of reconnect attempts before giving up with .
+ /// Defaults high so a session survives long connectivity gaps (tunnels, rural dead zones on a
+ /// drive): with the backoff capped at , ~60 attempts is roughly half an
+ /// hour of retrying. A returning network can also resume sooner via
+ /// (e.g. driven by an Android connectivity callback).
+ ///
+ public int MaxAttempts { get; init; } = 60;
/// Delay before the first reconnect attempt.
public TimeSpan InitialDelay { get; init; } = TimeSpan.FromSeconds(1);
diff --git a/src/SharpClient.Core/Connection/TelnetConnection.cs b/src/SharpClient.Core/Connection/TelnetConnection.cs
index d1086e2..724e063 100644
--- a/src/SharpClient.Core/Connection/TelnetConnection.cs
+++ b/src/SharpClient.Core/Connection/TelnetConnection.cs
@@ -21,6 +21,17 @@ public sealed class TelnetConnection : ITelnetConnection
private int _port;
private bool _intentionalDisconnect;
+ private CancellationTokenSource? _heartbeatCts;
+ private Task? _heartbeatTask;
+
+ // Telnet NOP (IAC NOP). Sent periodically as an application-level heartbeat: it keeps the MUSH
+ // from idle-timing us out and forces a small write, so a silently-dead socket surfaces quickly.
+ private static readonly byte[] s_iacNop = [255, 241];
+
+ // Heartbeat cadence. Short enough to detect a dead peer and keep the server session alive on a
+ // long-running (e.g. in-car) connection, without meaningful battery cost.
+ private static readonly TimeSpan HeartbeatInterval = TimeSpan.FromSeconds(45);
+
public TelnetConnection(ITelnetInterpreterFactory factory, ReconnectOptions? reconnectOptions = null)
{
_factory = factory;
@@ -66,6 +77,27 @@ public async Task ConnectAsync(string host, int port, CancellationToken cancella
SetState(ConnectionState.Error);
throw;
}
+
+ StartHeartbeat();
+ }
+
+ ///
+ /// Immediately (re)connects to the last endpoint, tearing down the current — possibly dead —
+ /// socket and any in-flight reconnect backoff first. Intended for external "the network just
+ /// changed / came back" signals (e.g. an Android connectivity callback) so a session recovers
+ /// at once instead of waiting out the backoff, and resumes even after auto-reconnect gave up
+ /// (). No-op if the user intentionally disconnected or the
+ /// connection was never established.
+ ///
+ public async Task ForceReconnectAsync()
+ {
+ var host = _host;
+ if (_intentionalDisconnect || host is null)
+ {
+ return;
+ }
+
+ await ConnectAsync(host, _port);
}
public async Task SendAsync(string line)
@@ -101,6 +133,8 @@ public async Task DisconnectAsync()
_intentionalDisconnect = true;
_reconnectCts?.Cancel();
+ await StopHeartbeatAsync();
+
if (_interpreter is not null)
{
await _interpreter.DisposeAsync();
@@ -132,6 +166,66 @@ public async Task DisconnectAsync()
public async ValueTask DisposeAsync() => await DisconnectAsync();
+ private static void TrySetTcpKeepAlive(Socket socket)
+ {
+ // Each option is best-effort: not every platform supports all three. Values are in seconds:
+ // start probing after 30s idle, probe every 10s, drop after 3 unanswered probes (~60s to death).
+ try { socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, 30); } catch { }
+ try { socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, 10); } catch { }
+ try { socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, 3); } catch { }
+ }
+
+ // Application-level heartbeat: spans the whole connect lifetime (including auto-reconnect gaps),
+ // sending a telnet NOP while Connected. Started on connect, stopped on intentional disconnect.
+ private void StartHeartbeat()
+ {
+ _heartbeatCts = new CancellationTokenSource();
+ _heartbeatTask = HeartbeatLoopAsync(_heartbeatCts.Token);
+ }
+
+ private async Task StopHeartbeatAsync()
+ {
+ _heartbeatCts?.Cancel();
+ if (_heartbeatTask is not null)
+ {
+ await AwaitQuietlyAsync(_heartbeatTask);
+ _heartbeatTask = null;
+ }
+ _heartbeatCts?.Dispose();
+ _heartbeatCts = null;
+ }
+
+ private async Task HeartbeatLoopAsync(CancellationToken token)
+ {
+ try
+ {
+ while (!token.IsCancellationRequested)
+ {
+ await Task.Delay(HeartbeatInterval, token);
+
+ // Only probe an established connection; during Reconnecting/Error the socket is gone.
+ if (State != ConnectionState.Connected || _interpreter is null)
+ {
+ continue;
+ }
+
+ try
+ {
+ await _interpreter.WriteToNetworkAsync(s_iacNop, CancellationToken.None);
+ }
+ catch
+ {
+ // The write failed — the socket is dead. Tear it down so the read loop completes
+ // and the monitor drives an auto-reconnect.
+ _client?.Dispose();
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ }
+
// Performs the actual TCP connect + telnet negotiation and starts the read-loop
// monitor. Used both for the initial connect and for each reconnect attempt.
private async Task EstablishConnectionAsync(string host, int port, CancellationToken cancellationToken)
@@ -140,6 +234,9 @@ private async Task EstablishConnectionAsync(string host, int port, CancellationT
// SO_KEEPALIVE lets the OS probe a silently-dead peer so a half-open socket
// eventually surfaces as a read-loop completion instead of hanging forever.
client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);
+ // Tune the keepalive so a dead peer surfaces in ~1 minute instead of the OS default
+ // (often ~2h idle) — important on mobile networks that change while moving.
+ TrySetTcpKeepAlive(client.Client);
await client.ConnectAsync(host, port, cancellationToken);
var (interpreter, readTask) = await _factory.CreateBuilder()
diff --git a/src/SharpClient.Core/Presentation/SessionsViewModel.cs b/src/SharpClient.Core/Presentation/SessionsViewModel.cs
index d88be74..f74bb44 100644
--- a/src/SharpClient.Core/Presentation/SessionsViewModel.cs
+++ b/src/SharpClient.Core/Presentation/SessionsViewModel.cs
@@ -31,6 +31,10 @@ public SessionsViewModel(ISessionManager manager)
public bool CanSend => Active?.State == ConnectionState.Connected && !string.IsNullOrWhiteSpace(Input);
+ /// True while the active session has a live-ish connection the user could drop.
+ public bool CanDisconnect =>
+ Active is { State: ConnectionState.Connected or ConnectionState.Connecting or ConnectionState.Reconnecting };
+
public IReadOnlyList History =>
Active is not null && _histories.TryGetValue(Active, out var h) ? h : [];
@@ -40,6 +44,13 @@ public SessionsViewModel(ISessionManager manager)
public Task CloseAsync(ISession session) => _manager.CloseAsync(session);
+ ///
+ /// Intentionally drop the active session's connection. Stops auto-reconnect (the user can
+ /// reconnect from the world list), which is the escape hatch when the client thinks it is
+ /// connected but the socket is actually dead.
+ ///
+ public Task DisconnectAsync() => Active?.DisconnectAsync() ?? Task.CompletedTask;
+
public async Task SendAsync()
{
if (!CanSend || Active is null)
diff --git a/src/SharpClient.Core/Sessions/ISession.cs b/src/SharpClient.Core/Sessions/ISession.cs
index ef1b2ec..2c2333f 100644
--- a/src/SharpClient.Core/Sessions/ISession.cs
+++ b/src/SharpClient.Core/Sessions/ISession.cs
@@ -28,6 +28,12 @@ public event Action? ProtocolChanged { add { } remove { } }
public Task ConnectAsync(string host, int port, CancellationToken cancellationToken = default);
+ /// Intentionally drop the connection (stops auto-reconnect) while keeping the session.
+ public Task DisconnectAsync() => Task.CompletedTask;
+
+ /// Force an immediate reconnect to the same endpoint (e.g. after a network change).
+ public Task ForceReconnectAsync() => Task.CompletedTask;
+
public Task SendAsync(string line);
public Task SendWindowSizeAsync(int cols, int rows) => Task.CompletedTask;
diff --git a/src/SharpClient.Core/Sessions/Session.cs b/src/SharpClient.Core/Sessions/Session.cs
index fa43fe6..ff3b755 100644
--- a/src/SharpClient.Core/Sessions/Session.cs
+++ b/src/SharpClient.Core/Sessions/Session.cs
@@ -107,6 +107,10 @@ public event Action? ProtocolChanged
public Task ConnectAsync(string host, int port, CancellationToken cancellationToken = default) =>
_connection.ConnectAsync(host, port, cancellationToken);
+ public Task DisconnectAsync() => _connection.DisconnectAsync();
+
+ public Task ForceReconnectAsync() => _connection.ForceReconnectAsync();
+
private static readonly TextStyle EchoStyle = TextStyle.Default with { Foreground = AnsiColor.Indexed(8) };
public async Task SendAsync(string line)
diff --git a/src/SharpClient.UI/Components/SessionScreen.razor b/src/SharpClient.UI/Components/SessionScreen.razor
index 2425b30..2a287b5 100644
--- a/src/SharpClient.UI/Components/SessionScreen.razor
+++ b/src/SharpClient.UI/Components/SessionScreen.razor
@@ -16,6 +16,17 @@
@* Connection state is already shown as a coloured dot on each tab (SessionTabs), so the
redundant "Connected" state pill has been removed from the header. *@
+ @if (Vm.CanDisconnect)
+ {
+
+ }
@if (ProtocolVm is not null)
{