Skip to content

Commit 39dd18d

Browse files
fix(connection): detect half-open sockets via TCP_USER_TIMEOUT
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHCRc5CJ6595iMzEgYYdQp
1 parent 208a65a commit 39dd18d

1 file changed

Lines changed: 32 additions & 0 deletions

File tree

src/SharpClient.Core/Connection/TelnetConnection.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,33 @@ private static void TrySetTcpKeepAlive(Socket socket)
175175
try { socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, 3); } catch { }
176176
}
177177

178+
// Milliseconds of unacknowledged, in-flight data before the OS fails the connection. Catches a
179+
// half-open peer where a sent command/heartbeat is never acked (the "I sent a command and nothing
180+
// came back" hang). Only trips on genuinely unacked bytes, so a slow-but-alive server never fires it.
181+
private const int TcpUserTimeoutMs = 20_000;
182+
183+
private static void TrySetTcpUserTimeout(Socket socket)
184+
{
185+
// TCP_USER_TIMEOUT is a Linux/Android option (there is no cross-platform SocketOptionName for it).
186+
if (!OperatingSystem.IsLinux() && !OperatingSystem.IsAndroid())
187+
{
188+
return;
189+
}
190+
191+
try
192+
{
193+
const int ipprotoTcp = 6; // IPPROTO_TCP
194+
const int tcpUserTimeout = 18; // TCP_USER_TIMEOUT
195+
Span<byte> value = stackalloc byte[sizeof(int)];
196+
BitConverter.TryWriteBytes(value, TcpUserTimeoutMs); // native byte order, as the option expects
197+
socket.SetRawSocketOption(ipprotoTcp, tcpUserTimeout, value);
198+
}
199+
catch
200+
{
201+
// Best-effort — keepalive still provides eventual detection.
202+
}
203+
}
204+
178205
// Application-level heartbeat: spans the whole connect lifetime (including auto-reconnect gaps),
179206
// sending a telnet NOP while Connected. Started on connect, stopped on intentional disconnect.
180207
private void StartHeartbeat()
@@ -237,6 +264,11 @@ private async Task EstablishConnectionAsync(string host, int port, CancellationT
237264
// Tune the keepalive so a dead peer surfaces in ~1 minute instead of the OS default
238265
// (often ~2h idle) — important on mobile networks that change while moving.
239266
TrySetTcpKeepAlive(client.Client);
267+
// Also bound retransmission of unacknowledged data. Keepalive only probes an IDLE socket;
268+
// when a command (or the NOP heartbeat) is in flight but never acked — a half-open peer —
269+
// TCP would otherwise retransmit for ~15 min before failing, so the client sits "connected"
270+
// with no responses. This makes that surface in ~20s instead.
271+
TrySetTcpUserTimeout(client.Client);
240272
await client.ConnectAsync(host, port, cancellationToken);
241273

242274
var (interpreter, readTask) = await _factory.CreateBuilder()

0 commit comments

Comments
 (0)