Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────────

Expand Down Expand Up @@ -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()
{
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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);
}
}
13 changes: 13 additions & 0 deletions src/SharpClient.App/Services/ConnectionKeepAliveCoordinator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -78,6 +90,7 @@ private void Evaluate()

public void Dispose()
{
NetworkChangeSignal.Changed -= OnNetworkChanged;
lock (_gate)
{
_sessions.Changed -= OnSessionsChanged;
Expand Down
17 changes: 17 additions & 0 deletions src/SharpClient.App/Services/NetworkChangeSignal.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace SharpClient.App.Services;

/// <summary>
/// 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; <see cref="ConnectionKeepAliveCoordinator"/> (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.
/// </summary>
internal static class NetworkChangeSignal
{
/// <summary>Raised when the active/default network changes (e.g. WiFi↔cellular, tower handoff).</summary>
public static event Action? Changed;

public static void RaiseChanged() => Changed?.Invoke();
}
7 changes: 7 additions & 0 deletions src/SharpClient.Core/Connection/ITelnetConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ public interface ITelnetConnection : IAsyncDisposable

public Task ConnectAsync(string host, int port, CancellationToken cancellationToken = default);

/// <summary>
/// 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.
/// </summary>
public Task ForceReconnectAsync();

public Task SendAsync(string line);

public Task DisconnectAsync();
Expand Down
10 changes: 8 additions & 2 deletions src/SharpClient.Core/Connection/ReconnectOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,14 @@ namespace SharpClient.Core.Connection;
/// </summary>
public sealed record ReconnectOptions
{
/// <summary>Maximum number of reconnect attempts before giving up with <see cref="ConnectionState.Error"/>.</summary>
public int MaxAttempts { get; init; } = 6;
/// <summary>
/// Maximum number of reconnect attempts before giving up with <see cref="ConnectionState.Error"/>.
/// Defaults high so a session survives long connectivity gaps (tunnels, rural dead zones on a
/// drive): with the backoff capped at <see cref="MaxDelay"/>, ~60 attempts is roughly half an
/// hour of retrying. A returning network can also resume sooner via
/// <see cref="TelnetConnection.ForceReconnectAsync"/> (e.g. driven by an Android connectivity callback).
/// </summary>
public int MaxAttempts { get; init; } = 60;

/// <summary>Delay before the first reconnect attempt.</summary>
public TimeSpan InitialDelay { get; init; } = TimeSpan.FromSeconds(1);
Expand Down
97 changes: 97 additions & 0 deletions src/SharpClient.Core/Connection/TelnetConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -66,6 +77,27 @@ public async Task ConnectAsync(string host, int port, CancellationToken cancella
SetState(ConnectionState.Error);
throw;
}

StartHeartbeat();
}

/// <summary>
/// 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
/// (<see cref="ConnectionState.Error"/>). No-op if the user intentionally disconnected or the
/// connection was never established.
/// </summary>
public async Task ForceReconnectAsync()
{
var host = _host;
if (_intentionalDisconnect || host is null)
{
return;
}

await ConnectAsync(host, _port);
}

public async Task SendAsync(string line)
Expand Down Expand Up @@ -101,6 +133,8 @@ public async Task DisconnectAsync()
_intentionalDisconnect = true;
_reconnectCts?.Cancel();

await StopHeartbeatAsync();

if (_interpreter is not null)
{
await _interpreter.DisposeAsync();
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand Down
11 changes: 11 additions & 0 deletions src/SharpClient.Core/Presentation/SessionsViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ public SessionsViewModel(ISessionManager manager)

public bool CanSend => Active?.State == ConnectionState.Connected && !string.IsNullOrWhiteSpace(Input);

/// <summary>True while the active session has a live-ish connection the user could drop.</summary>
public bool CanDisconnect =>
Active is { State: ConnectionState.Connected or ConnectionState.Connecting or ConnectionState.Reconnecting };

public IReadOnlyList<string> History =>
Active is not null && _histories.TryGetValue(Active, out var h) ? h : [];

Expand All @@ -40,6 +44,13 @@ public SessionsViewModel(ISessionManager manager)

public Task CloseAsync(ISession session) => _manager.CloseAsync(session);

/// <summary>
/// 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.
/// </summary>
public Task DisconnectAsync() => Active?.DisconnectAsync() ?? Task.CompletedTask;

public async Task SendAsync()
{
if (!CanSend || Active is null)
Expand Down
6 changes: 6 additions & 0 deletions src/SharpClient.Core/Sessions/ISession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ public event Action? ProtocolChanged { add { } remove { } }

public Task ConnectAsync(string host, int port, CancellationToken cancellationToken = default);

/// <summary>Intentionally drop the connection (stops auto-reconnect) while keeping the session.</summary>
public Task DisconnectAsync() => Task.CompletedTask;

/// <summary>Force an immediate reconnect to the same endpoint (e.g. after a network change).</summary>
public Task ForceReconnectAsync() => Task.CompletedTask;

public Task SendAsync(string line);

public Task SendWindowSizeAsync(int cols, int rows) => Task.CompletedTask;
Expand Down
Loading
Loading