fix(connection): half-open detection (TCP_USER_TIMEOUT) + Connect/Disconnect toggle#23
Conversation
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
There was a problem hiding this comment.
Pull request overview
This PR improves connection resilience by proactively detecting half-open TCP connections: it applies TCP_USER_TIMEOUT (Linux/Android) so unacknowledged in-flight data fails fast, allowing the existing read-loop monitor to trigger auto-reconnect rather than hanging for extended OS-level retransmission timeouts.
Changes:
- Add a Linux/Android-only
TCP_USER_TIMEOUTsocket option setter (TrySetTcpUserTimeout) with best-effort behavior. - Apply
TCP_USER_TIMEOUTduring connection establishment alongside existing keepalive tuning.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const int ipprotoTcp = 6; // IPPROTO_TCP | ||
| const int tcpUserTimeout = 18; // TCP_USER_TIMEOUT | ||
| Span<byte> value = stackalloc byte[sizeof(int)]; | ||
| BitConverter.TryWriteBytes(value, TcpUserTimeoutMs); // native byte order, as the option expects |
Verified: the 20s aligns with the reconnect scheduleBecause
So the recovery timeline stays predictable: Every attempt is Note: the earlier |
…down 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHCRc5CJ6595iMzEgYYdQp
Added: header control now toggles Disconnect ⇄ ConnectPer request, the session-header control is now a toggle instead of Disconnect-only:
New Adds |
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(added earlier) only probe an idle socket. The instant a command — or the 45s NOP heartbeat — is sent, that data is in flight; on a half-open peer it's never acknowledged, so keepalive never runs. TCP instead retransmits the unacked data for ~15 minutes (tcp_retries2) before failing, and the read loop stays blocked the whole time, so no auto-reconnect fires.Fix
Set
TCP_USER_TIMEOUT(Linux/Android) to 20s — it 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, per the auto-login fix, re-authenticates).Key property: it only trips on genuinely unacked bytes, so a slow-but-alive server (still ACKing at the TCP layer, just slow to emit output) never false-fires. Best-effort and platform-guarded (no-op off Linux/Android); keepalive stays the fallback.
Verification
20000→GetRawSocketOptionreads back20000(confirms option levelIPPROTO_TCP=6, nameTCP_USER_TIMEOUT=18, and native endianness are correct and the kernel accepts it).Builds on the connection-resilience work; the Disconnect button remains the manual escape hatch.
🤖 Generated with Claude Code