From deeda90a5e7f29dfc70d673cb3439095689bce83 Mon Sep 17 00:00:00 2001 From: HarryCordewener Date: Wed, 1 Jul 2026 08:23:17 -0500 Subject: [PATCH] feat(connection): resilient reconnect for changing networks + Disconnect button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes a session survive the network churn of a long drive (WiFi↔cellular, tower handoffs, dead zones) and adds a manual escape hatch. Core: - ForceReconnectAsync() on TelnetConnection/ITelnetConnection — reconnect now to the last endpoint, tearing down the dead socket and any backoff; resumes even after auto-reconnect gave up (Error). No-op after an intentional disconnect. - Tune TCP keepalive (30s idle / 10s interval / 3 probes) so a silently-dead peer surfaces in ~1 min instead of the OS default (~2h). - Application-level IAC NOP heartbeat (45s) that keeps the server session alive and drops+reconnects when a write fails. - ReconnectOptions.Default retries far longer (MaxAttempts 6 → 60, ~30 min) so a long dead zone doesn't permanently give up. - Intentional DisconnectAsync + ForceReconnectAsync on ISession/Session. Android: - Keep-alive service now watches the DEFAULT network: on a change it pins the process to the new network (BindProcessToNetwork) and signals a reconnect; ConnectionKeepAliveCoordinator turns that into an immediate ForceReconnect on every session. NetworkChangeSignal bridges the two without the coordinator referencing Android-only types. UI: - Disconnect button in the session header (drops the connection / stops auto-reconnect) for when the client thinks it's connected but the socket is dead. Builds off the reconnect auto-login fix so a reconnected session re-authenticates. Core 198 / UI 47 / Data 17 tests pass; Android head builds clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HHCRc5CJ6595iMzEgYYdQp --- .../Android/ConnectionKeepAliveService.cs | 52 ++++++++-- .../ConnectionKeepAliveCoordinator.cs | 13 +++ .../Services/NetworkChangeSignal.cs | 17 ++++ .../Connection/ITelnetConnection.cs | 7 ++ .../Connection/ReconnectOptions.cs | 10 +- .../Connection/TelnetConnection.cs | 97 +++++++++++++++++++ .../Presentation/SessionsViewModel.cs | 11 +++ src/SharpClient.Core/Sessions/ISession.cs | 6 ++ src/SharpClient.Core/Sessions/Session.cs | 4 + .../Components/SessionScreen.razor | 17 ++++ src/SharpClient.UI/wwwroot/app.css | 27 ++++++ .../Sessions/FakeTelnetConnection.cs | 9 ++ .../Sessions/SessionStateTests.cs | 23 +++++ 13 files changed, 282 insertions(+), 11 deletions(-) create mode 100644 src/SharpClient.App/Services/NetworkChangeSignal.cs 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) {