Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/SharpClient.Core/Connection/ITelnetConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ public interface ITelnetConnection : IAsyncDisposable
/// </summary>
public Task ForceReconnectAsync();

/// <summary>User-initiated reconnect to the last endpoint; works even after an intentional disconnect.</summary>
public Task ReconnectAsync();

public Task SendAsync(string line);

public Task DisconnectAsync();
Expand Down
48 changes: 48 additions & 0 deletions src/SharpClient.Core/Connection/TelnetConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,22 @@ public async Task ForceReconnectAsync()
await ConnectAsync(host, _port);
}

/// <summary>
/// User-initiated reconnect to the last endpoint. Unlike <see cref="ForceReconnectAsync"/> (the
/// automatic network-change path), this deliberately reconnects even after an intentional
/// <see cref="DisconnectAsync"/> — it backs the header's Connect button. No-op if we never connected.
/// </summary>
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)
Expand Down Expand Up @@ -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<byte> 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()
Expand Down Expand Up @@ -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()
Expand Down
7 changes: 7 additions & 0 deletions src/SharpClient.Core/Presentation/SessionsViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ public SessionsViewModel(ISessionManager manager)
public bool CanDisconnect =>
Active is { State: ConnectionState.Connected or ConnectionState.Connecting or ConnectionState.Reconnecting };

/// <summary>True while the active session is down and could be reconnected in place.</summary>
public bool CanConnect =>
Active is { State: ConnectionState.Disconnected or ConnectionState.Error };

public IReadOnlyList<string> History =>
Active is not null && _histories.TryGetValue(Active, out var h) ? h : [];

Expand All @@ -51,6 +55,9 @@ public SessionsViewModel(ISessionManager manager)
/// </summary>
public Task DisconnectAsync() => Active?.DisconnectAsync() ?? Task.CompletedTask;

/// <summary>Reconnect the active (down) session to its endpoint, in place.</summary>
public Task ReconnectAsync() => Active?.ReconnectAsync() ?? Task.CompletedTask;

public async Task SendAsync()
{
if (!CanSend || Active is null)
Expand Down
3 changes: 3 additions & 0 deletions src/SharpClient.Core/Sessions/ISession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ public event Action? ProtocolChanged { add { } remove { } }
/// <summary>Force an immediate reconnect to the same endpoint (e.g. after a network change).</summary>
public Task ForceReconnectAsync() => Task.CompletedTask;

/// <summary>User-initiated reconnect to the last endpoint; works even after an intentional disconnect.</summary>
public Task ReconnectAsync() => Task.CompletedTask;

public Task SendAsync(string line);

public Task SendWindowSizeAsync(int cols, int rows) => Task.CompletedTask;
Expand Down
2 changes: 2 additions & 0 deletions src/SharpClient.Core/Sessions/Session.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
17 changes: 17 additions & 0 deletions src/SharpClient.UI/Components/SessionScreen.razor
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@
</svg>
</button>
}
else if (Vm.CanConnect)
{
<button class="sc-connect-btn" @onclick="ReconnectAsync"
title="Reconnect this session" aria-label="Connect">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M10 3.5v6" />
<path d="M6.2 6.2a5 5 0 1 0 7.6 0" />
</svg>
</button>
}
@if (ProtocolVm is not null)
{
<button class="sc-protocol-toggle @(_showProtocol ? "sc-protocol-toggle-active" : string.Empty)"
Expand Down Expand Up @@ -73,6 +84,12 @@
await Vm.DisconnectAsync();
await InvokeAsync(StateHasChanged);
}

private async Task ReconnectAsync()
{
await Vm.ReconnectAsync();
await InvokeAsync(StateHasChanged);
}
private ElementReference _outputRef;
private IJSObjectReference? _interop;
private DotNetObjectReference<SessionScreen>? _dotNetRef;
Expand Down
26 changes: 26 additions & 0 deletions src/SharpClient.UI/wwwroot/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,32 @@ body {
background: rgba(255, 107, 107, .08);
}

/* Connect button — same spot/glyph as Disconnect, accent-tinted to invite reconnecting a down session. */
.sc-connect-btn {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 8px;
background: var(--acc-soft);
border: 1px solid var(--acc-line);
color: var(--acc2);
cursor: pointer;
transition: color .12s, border-color .12s, background .12s;
}

.sc-connect-btn svg {
width: 17px;
height: 17px;
display: block;
}

.sc-connect-btn:hover {
color: var(--tx);
border-color: var(--acc2);
}

/* ── Protocol panel (negotiation + GMCP inspector) ─────────────── */
.sc-protocol-panel {
flex-shrink: 0;
Expand Down
9 changes: 9 additions & 0 deletions tests/SharpClient.Tests/Sessions/FakeTelnetConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ public Task ForceReconnectAsync()
return Task.CompletedTask;
}

public int ReconnectCount { get; private set; }

public Task ReconnectAsync()
{
ReconnectCount++;
RaiseState(ConnectionState.Connected);
return Task.CompletedTask;
}

public Task SendAsync(string line)
{
Sent.Add(line);
Expand Down
11 changes: 11 additions & 0 deletions tests/SharpClient.Tests/Sessions/SessionStateTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,17 @@ public async Task SessionForceReconnectDelegatesToConnection()
await Assert.That(fake.ForceReconnectCount).IsEqualTo(1);
}

[Test]
public async Task SessionReconnectDelegatesToConnection()
{
var fake = new FakeTelnetConnection();
await using var session = new Session(fake);

await session.ReconnectAsync();

await Assert.That(fake.ReconnectCount).IsEqualTo(1);
}

[Test]
public async Task ReconnectingAndErrorAreDistinctStates()
{
Expand Down
Loading