From 39dd18d93d2bdc3df93a8b95b485c82df8259c40 Mon Sep 17 00:00:00 2001 From: HarryCordewener Date: Wed, 1 Jul 2026 10:54:22 -0500 Subject: [PATCH 1/2] fix(connection): detect half-open sockets via TCP_USER_TIMEOUT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symptom: you run a command and nothing comes back — the connection is actually dead (server gone / network dropped) but the client sits "Connected", showing the local echo with no server response, until you force a reconnect. Cause: SO_KEEPALIVE / TcpKeepAliveTime only probe an IDLE socket. The moment a command (or the NOP heartbeat) is sent, that data is in flight; on a half-open peer it is never acknowledged, so keepalive doesn't run and TCP instead retransmits the unacked data for ~15 minutes (tcp_retries2) before failing. The read loop stays blocked the whole time, so no auto-reconnect fires. Fix: set TCP_USER_TIMEOUT (Linux/Android) to 20s, which bounds how long unacknowledged in-flight data is retransmitted before the OS fails the socket. A half-open connection now surfaces in ~20s → the read loop completes → the existing monitor auto-reconnects (and re-authenticates). It only trips on genuinely unacked bytes, so a slow-but-alive server (still ACKing at the TCP layer) never false-fires. Best-effort + platform-guarded; keepalive remains the fallback elsewhere. Verified the raw option round-trips on Linux (set 20000 → read back 20000); Core suite (198) passes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HHCRc5CJ6595iMzEgYYdQp --- .../Connection/TelnetConnection.cs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/SharpClient.Core/Connection/TelnetConnection.cs b/src/SharpClient.Core/Connection/TelnetConnection.cs index 724e063..fb7ae15 100644 --- a/src/SharpClient.Core/Connection/TelnetConnection.cs +++ b/src/SharpClient.Core/Connection/TelnetConnection.cs @@ -175,6 +175,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 +264,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() From 514ac3490fe8dde513d4f136b05d17cdbaa94f72 Mon Sep 17 00:00:00 2001 From: HarryCordewener Date: Wed, 1 Jul 2026 11:14:13 -0500 Subject: [PATCH 2/2] feat(ui): make the session Disconnect control toggle to Connect when down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session-header button dropped a live connection but left no way to bring it back in place — you had to go back to the world list. Now the same spot toggles: Disconnect while connected/connecting/reconnecting, and Connect (reconnect to the last endpoint) once the session is Disconnected or Error. - TelnetConnection/ISession/Session: ReconnectAsync() — user-initiated reconnect to the last endpoint that works even after an intentional DisconnectAsync (distinct from ForceReconnectAsync, the automatic network-change path which stays a no-op after an intentional disconnect). - SessionsViewModel: CanConnect + ReconnectAsync(). - SessionScreen: the header control renders Disconnect (CanDisconnect) or Connect (CanConnect), accent-tinted to invite reconnecting. Adds SessionReconnectDelegatesToConnection; Core 199 / UI 47 pass; Web builds clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HHCRc5CJ6595iMzEgYYdQp --- .../Connection/ITelnetConnection.cs | 3 +++ .../Connection/TelnetConnection.cs | 16 ++++++++++++ .../Presentation/SessionsViewModel.cs | 7 +++++ src/SharpClient.Core/Sessions/ISession.cs | 3 +++ src/SharpClient.Core/Sessions/Session.cs | 2 ++ .../Components/SessionScreen.razor | 17 ++++++++++++ src/SharpClient.UI/wwwroot/app.css | 26 +++++++++++++++++++ .../Sessions/FakeTelnetConnection.cs | 9 +++++++ .../Sessions/SessionStateTests.cs | 11 ++++++++ 9 files changed, 94 insertions(+) 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 fb7ae15..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) 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) {