Skip to content

fix(connection): half-open detection (TCP_USER_TIMEOUT) + Connect/Disconnect toggle#23

Merged
HarryCordewener merged 2 commits into
masterfrom
fix/half-open-detection
Jul 1, 2026
Merged

fix(connection): half-open detection (TCP_USER_TIMEOUT) + Connect/Disconnect toggle#23
HarryCordewener merged 2 commits into
masterfrom
fix/half-open-detection

Conversation

@HarryCordewener

Copy link
Copy Markdown
Member

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

  • Round-tripped the raw option on Linux: set 20000GetRawSocketOption reads back 20000 (confirms option level IPPROTO_TCP=6, name TCP_USER_TIMEOUT=18, and native endianness are correct and the kernel accepts it).
  • Core suite (198) passes; the real-socket reconnect tests exercise the new setup without error.
  • Note: exercising the actual half-open timeout requires a real packet-drop partition, which isn't unit-testable here — the option is confirmed applied and the mechanism is standard.

Builds on the connection-resilience work; the Disconnect button remains the manual escape hatch.

🤖 Generated with Claude Code

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
Copilot AI review requested due to automatic review settings July 1, 2026 15:54

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_TIMEOUT socket option setter (TrySetTcpUserTimeout) with best-effort behavior.
  • Apply TCP_USER_TIMEOUT during 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
@HarryCordewener

Copy link
Copy Markdown
Member Author

Verified: the 20s aligns with the reconnect schedule

Because TCP_USER_TIMEOUT is set before connect(), it bounds two things, not just established-connection death:

  1. A dead established connection (unacked sent data) → socket fails at ~20s.
  2. Each reconnect attempt's connect() → tested a connect to a blackhole address (RFC-5737 192.0.2.1): it fails at exactly 20.0s instead of the OS default (~130s of SYN retries).

So the recovery timeline stays predictable:

command sent → (unacked) → ~20s → socket fails → read loop ends → Reconnecting
  → wait 1s → connect (≤20s) → wait 2s → connect (≤20s) → … (backoff caps at 30s)

Every attempt is ≤20s connect + its backoff delay, and since 20s < the 30s max backoff, attempts never outrun the cadence or hang indefinitely on a still-down network. Without the option, each failed attempt would hang ~130s and the 60-attempt budget would balloon to hours.

Note: the earlier SO_KEEPALIVE tuning (~60s) is now effectively a cross-platform fallback — on Linux/Android TCP_USER_TIMEOUT (20s) overrides keepalive teardown; keepalive only drives timing on the Windows/macOS heads where this option is skipped. 20s is the single knob for both detection and per-attempt connect latency.

…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
@HarryCordewener HarryCordewener changed the title fix(connection): detect half-open sockets via TCP_USER_TIMEOUT fix(connection): half-open detection (TCP_USER_TIMEOUT) + Connect/Disconnect toggle Jul 1, 2026
@HarryCordewener

Copy link
Copy Markdown
Member Author

Added: header control now toggles Disconnect ⇄ Connect

Per request, the session-header control is now a toggle instead of Disconnect-only:

  • Disconnect while Connected / Connecting / Reconnecting.
  • Connect (reconnect to the last endpoint, in place) once the session is Disconnected or Error — no need to go back to the world list.

New ReconnectAsync() on TelnetConnection/ISession/Session is the user-initiated reconnect: it reconnects to the last endpoint even after an intentional DisconnectAsync (distinct from ForceReconnectAsync, the automatic network-change path, which stays a no-op after an intentional disconnect). SessionsViewModel gains CanConnect + ReconnectAsync(); the header renders the right button by state.

Adds SessionReconnectDelegatesToConnection; Core 199 / UI 47 pass, Web builds clean. (I tried to grab a before/after screenshot of the toggle, but the live server rate-limited my repeated test connects, so I'm relying on the unit tests + build here.)

@HarryCordewener
HarryCordewener merged commit 3e09b5e into master Jul 1, 2026
2 checks passed
@HarryCordewener
HarryCordewener deleted the fix/half-open-detection branch July 1, 2026 16:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants