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) {