diff --git a/src/SharpClient.Core/Connection/ITelnetConnection.cs b/src/SharpClient.Core/Connection/ITelnetConnection.cs
index ac56a7b..46f26a9 100644
--- a/src/SharpClient.Core/Connection/ITelnetConnection.cs
+++ b/src/SharpClient.Core/Connection/ITelnetConnection.cs
@@ -24,6 +24,9 @@ public interface ITelnetConnection : IAsyncDisposable
///
public Task ForceReconnectAsync();
+ /// User-initiated reconnect to the last endpoint; works even after an intentional disconnect.
+ public Task ReconnectAsync();
+
public Task SendAsync(string line);
public Task DisconnectAsync();
diff --git a/src/SharpClient.Core/Connection/TelnetConnection.cs b/src/SharpClient.Core/Connection/TelnetConnection.cs
index 724e063..163453d 100644
--- a/src/SharpClient.Core/Connection/TelnetConnection.cs
+++ b/src/SharpClient.Core/Connection/TelnetConnection.cs
@@ -100,6 +100,22 @@ public async Task ForceReconnectAsync()
await ConnectAsync(host, _port);
}
+ ///
+ /// User-initiated reconnect to the last endpoint. Unlike (the
+ /// automatic network-change path), this deliberately reconnects even after an intentional
+ /// — it backs the header's Connect button. No-op if we never connected.
+ ///
+ public async Task ReconnectAsync()
+ {
+ var host = _host;
+ if (host is null)
+ {
+ return;
+ }
+
+ await ConnectAsync(host, _port);
+ }
+
public async Task SendAsync(string line)
{
if (_interpreter is null)
@@ -175,6 +191,33 @@ private static void TrySetTcpKeepAlive(Socket socket)
try { socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, 3); } catch { }
}
+ // Milliseconds of unacknowledged, in-flight data before the OS fails the connection. Catches a
+ // half-open peer where a sent command/heartbeat is never acked (the "I sent a command and nothing
+ // came back" hang). Only trips on genuinely unacked bytes, so a slow-but-alive server never fires it.
+ private const int TcpUserTimeoutMs = 20_000;
+
+ private static void TrySetTcpUserTimeout(Socket socket)
+ {
+ // TCP_USER_TIMEOUT is a Linux/Android option (there is no cross-platform SocketOptionName for it).
+ if (!OperatingSystem.IsLinux() && !OperatingSystem.IsAndroid())
+ {
+ return;
+ }
+
+ try
+ {
+ const int ipprotoTcp = 6; // IPPROTO_TCP
+ const int tcpUserTimeout = 18; // TCP_USER_TIMEOUT
+ Span value = stackalloc byte[sizeof(int)];
+ BitConverter.TryWriteBytes(value, TcpUserTimeoutMs); // native byte order, as the option expects
+ socket.SetRawSocketOption(ipprotoTcp, tcpUserTimeout, value);
+ }
+ catch
+ {
+ // Best-effort — keepalive still provides eventual detection.
+ }
+ }
+
// 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()
@@ -237,6 +280,11 @@ private async Task EstablishConnectionAsync(string host, int port, CancellationT
// 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);
+ // Also bound retransmission of unacknowledged data. Keepalive only probes an IDLE socket;
+ // when a command (or the NOP heartbeat) is in flight but never acked — a half-open peer —
+ // TCP would otherwise retransmit for ~15 min before failing, so the client sits "connected"
+ // with no responses. This makes that surface in ~20s instead.
+ TrySetTcpUserTimeout(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 f74bb44..310c91a 100644
--- a/src/SharpClient.Core/Presentation/SessionsViewModel.cs
+++ b/src/SharpClient.Core/Presentation/SessionsViewModel.cs
@@ -35,6 +35,10 @@ public SessionsViewModel(ISessionManager manager)
public bool CanDisconnect =>
Active is { State: ConnectionState.Connected or ConnectionState.Connecting or ConnectionState.Reconnecting };
+ /// True while the active session is down and could be reconnected in place.
+ public bool CanConnect =>
+ Active is { State: ConnectionState.Disconnected or ConnectionState.Error };
+
public IReadOnlyList History =>
Active is not null && _histories.TryGetValue(Active, out var h) ? h : [];
@@ -51,6 +55,9 @@ public SessionsViewModel(ISessionManager manager)
///
public Task DisconnectAsync() => Active?.DisconnectAsync() ?? Task.CompletedTask;
+ /// Reconnect the active (down) session to its endpoint, in place.
+ public Task ReconnectAsync() => Active?.ReconnectAsync() ?? 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 2c2333f..36e67e1 100644
--- a/src/SharpClient.Core/Sessions/ISession.cs
+++ b/src/SharpClient.Core/Sessions/ISession.cs
@@ -34,6 +34,9 @@ public event Action? ProtocolChanged { add { } remove { } }
/// Force an immediate reconnect to the same endpoint (e.g. after a network change).
public Task ForceReconnectAsync() => Task.CompletedTask;
+ /// User-initiated reconnect to the last endpoint; works even after an intentional disconnect.
+ public Task ReconnectAsync() => 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 ff3b755..3995cd1 100644
--- a/src/SharpClient.Core/Sessions/Session.cs
+++ b/src/SharpClient.Core/Sessions/Session.cs
@@ -111,6 +111,8 @@ public Task ConnectAsync(string host, int port, CancellationToken cancellationTo
public Task ForceReconnectAsync() => _connection.ForceReconnectAsync();
+ public Task ReconnectAsync() => _connection.ReconnectAsync();
+
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 2a287b5..9d76fe8 100644
--- a/src/SharpClient.UI/Components/SessionScreen.razor
+++ b/src/SharpClient.UI/Components/SessionScreen.razor
@@ -27,6 +27,17 @@
}
+ else if (Vm.CanConnect)
+ {
+
+ }
@if (ProtocolVm is not null)
{