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
5 changes: 4 additions & 1 deletion src/SharpMUTerm.Core/Telnet/ITelnetSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ public interface ITelnetSession : IAsyncDisposable
/// <summary>Sends a GMCP message.</summary>
ValueTask SendGmcpAsync(string package, string json, CancellationToken cancellationToken = default);

/// <summary>Reports the terminal window size to the server via NAWS.</summary>
/// <summary>
/// Reports the terminal window size via NAWS. Before the server enables NAWS, retains the latest
/// size and reports it automatically when negotiation completes; unchanged sizes are not resent.
/// </summary>
ValueTask SetWindowSizeAsync(int width, int height);

/// <summary>Closes the session.</summary>
Expand Down
32 changes: 17 additions & 15 deletions src/SharpMUTerm.Core/Telnet/TelnetSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -399,21 +399,24 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)

private Task<TelnetInterpreter> BuildInterpreterAsync()
{
var builder = new TelnetInterpreterBuilder()
TelnetInterpreterBuilder builder = new TelnetInterpreterBuilder()
.UseMode(TelnetInterpreter.TelnetMode.Client)
.UseLogger(_logger)
.OnNegotiation(WriteToTransportAsync)
.OnSubmit(OnSubmitAsync)
.AddDefaultMUDProtocols(
onNAWS: static (_, _) => ValueTask.CompletedTask,
onGMCPMessage: OnGmcpAsync,
onMSSP: OnMsspAsync,
msspConfig: static () => new MSSPConfig(),
onMSDPMessage: OnMsdpAsync,
onPrompt: OnPromptAsync,
charsetOrder: _options.CharsetOrder,
onCompressionEnabled: OnCompressionAsync,
onMXPEnabled: OnMxpEnabledAsync);
// Explicit defaults let NAWS observe negotiation completion, so an early pane report
// is retained and sent on DO NAWS rather than silently discarded by the base plugin.
.AddPlugin(new WindowSizeProtocol().OnNAWS(static (_, _) => ValueTask.CompletedTask))
.AddPlugin<GMCPProtocol>().OnGMCPMessage(OnGmcpAsync)
.AddPlugin<MSDPProtocol>().OnMSDPMessage(OnMsdpAsync)
.AddPlugin<MSSPProtocol>().OnMSSP(OnMsspAsync).WithMSSPConfig(static () => new MSSPConfig())
.AddPlugin<TerminalTypeProtocol>()
.AddPlugin<CharsetProtocol>().WithCharsetOrder(_options.CharsetOrder)
.AddPlugin<EORProtocol>().OnPrompt(OnPromptAsync)
.AddPlugin<SuppressGoAheadProtocol>().OnPrompt(OnPromptAsync)
.AddPlugin<PacketPatchProtocol>().OnPrompt(OnPromptAsync)
.AddPlugin<MXPProtocol>().OnMXPEnabled(OnMxpEnabledAsync)
.AddPlugin<MCCPProtocol>().OnCompressionEnabled(OnCompressionAsync);

// An idle keepalive, when the world asks for one: IAC NOP after the configured silence, so a
// NAT or load balancer doesn't evict a connection that is merely quiet. It is filler traffic,
Expand Down Expand Up @@ -689,10 +692,9 @@ public ValueTask SendGmcpAsync(string package, string json, CancellationToken ca
public ValueTask SetWindowSizeAsync(int width, int height)
{
var interpreter = _interpreter ?? throw new InvalidOperationException("Session is not connected.");
// SendNAWS was removed in 2.8.1. The NAWS plugin now owns the send, reports the full
// RFC 1073 unsigned-16 range, and takes int to avoid the caller having to cast.
return interpreter.PluginManager?.GetPlugin<NAWSProtocol>()?.SendWindowSizeAsync(width, height)
?? ValueTask.CompletedTask;
var protocol = interpreter.PluginManager?.GetPlugin<NAWSProtocol>() as WindowSizeProtocol
?? throw new InvalidOperationException("Window size protocol is not configured.");
return protocol.ReportAsync(width, height);
}

public async Task DisconnectAsync()
Expand Down
67 changes: 67 additions & 0 deletions src/SharpMUTerm.Core/Telnet/WindowSizeProtocol.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
using TelnetNegotiationCore.Protocols;

namespace SharpMUTerm.Core.Telnet;

/// <summary>
/// Retains the latest pane dimensions until the server enables NAWS. A successful request before
/// DO NAWS means queued, not written: TNC deliberately skips premature subnegotiations.
/// </summary>
internal sealed class WindowSizeProtocol : NAWSProtocol
{
private readonly SemaphoreSlim _reportGate = new(1, 1);
private (int Width, int Height)? _requested;
private (int Width, int Height)? _reported;

public async ValueTask ReportAsync(int width, int height)
{
ArgumentOutOfRangeException.ThrowIfNegative(width);
ArgumentOutOfRangeException.ThrowIfGreaterThan(width, MaxWindowDimension);
ArgumentOutOfRangeException.ThrowIfNegative(height);
ArgumentOutOfRangeException.ThrowIfGreaterThan(height, MaxWindowDimension);

await _reportGate.WaitAsync().ConfigureAwait(false);
try
{
_requested = (width, height);
await ReportIfReadyAsync().ConfigureAwait(false);
}
finally
{
_reportGate.Release();
}
}

protected override async ValueTask OnNegotiationChangedAsync(bool isNegotiated)
{
await _reportGate.WaitAsync().ConfigureAwait(false);
try
{
// A new agreement needs its own initial report, even if the pane has not changed.
if (!isNegotiated)
{
_reported = null;
}
else
{
await ReportIfReadyAsync().ConfigureAwait(false);
}
}
finally
{
_reportGate.Release();
}
}

private async ValueTask ReportIfReadyAsync()
{
if (!IsNegotiated || !WindowSizeReportingEnabled || _requested is not { } size || _reported == size)
{
return;
}

// TNC owns the binary framing and IAC escaping. Remember success only after the write,
// so a failed write can be retried with the same dimensions.
await SendWindowSizeAsync(size.Width, size.Height).ConfigureAwait(false);
_reported = size;
}
}
64 changes: 64 additions & 0 deletions tests/SharpMUTerm.Core.Tests/Telnet/TelnetSessionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ public class TelnetSessionTests
private const byte GA = 249;
private const byte EOR = 239;
private const byte NAWS = 31;
private const byte DO = 253;
private const byte ECHO = 1;
private const byte SGA = 3;

Expand Down Expand Up @@ -92,6 +93,69 @@ private static async Task<bool> WaitUntilAsync(Func<bool> condition, int timeout
return condition();
}

[Test]
public async Task WindowSizeBeforeNegotiation_IsSentWhenServerEnablesNaws()
{
var transport = new FakeTransport();
await using var session = new TelnetSession(transport);
await session.ConnectAsync();
await session.SetWindowSizeAsync(140, 45);
await session.SetWindowSizeAsync(150, 46);
await Assert.That(Contains(transport.SentBytes, IAC, 250, NAWS)).IsFalse();

// Fragmented DO, with no following text or resize to trigger a retry.
transport.FeedInbound(IAC);
transport.FeedInbound(DO);
transport.FeedInbound(NAWS);
await Assert.That(await WaitUntilAsync(() =>
Contains(transport.SentBytes, IAC, 250, NAWS, 0, 150, 0, 46, IAC, 240))).IsTrue();
await Assert.That(Contains(transport.SentBytes, IAC, 250, NAWS, 0, 140)).IsFalse();
}

[Test]
public async Task WindowSizeAfterNegotiation_SendsEscapedDimensionsAndSkipsDuplicates()
{
var transport = new FakeTransport();
await using var session = new TelnetSession(transport);
var waiter = new OutputWaiter();
waiter.Attach(session);
await session.ConnectAsync();
transport.FeedInbound(IAC, DO, NAWS, (byte)'o', (byte)'k', 13, 10);
await waiter.WaitAsync(e => e.Text == "ok");

await session.SetWindowSizeAsync(255, 240);
await Assert.That(Contains(transport.SentBytes,
IAC, 250, NAWS, 0, IAC, IAC, 0, 240, IAC, 240)).IsTrue();
var length = transport.SentBytes.Length;
await session.SetWindowSizeAsync(255, 240);
await Assert.That(transport.SentBytes.Length).IsEqualTo(length);
}

[Test]
[Arguments(140, 45)]
[Arguments(160, 50)]
public async Task NawsDisabledThenEnabled_ReportsLatestSizeAgain(int width, int height)
{
var transport = new FakeTransport();
await using var session = new TelnetSession(transport);
var waiter = new OutputWaiter();
waiter.Attach(session);
await session.ConnectAsync();
await session.SetWindowSizeAsync(140, 45);
transport.FeedInbound(IAC, DO, NAWS);
await Assert.That(await WaitUntilAsync(() =>
Contains(transport.SentBytes, IAC, 250, NAWS, 0, 140, 0, 45, IAC, 240))).IsTrue();

transport.FeedInbound(IAC, 254, NAWS, (byte)'o', (byte)'f', (byte)'f', 13, 10);
await waiter.WaitAsync(e => e.Text == "off");
var length = transport.SentBytes.Length;
await session.SetWindowSizeAsync(width, height);
await Assert.That(transport.SentBytes.Length).IsEqualTo(length);
transport.FeedInbound(IAC, DO, NAWS);
await Assert.That(await WaitUntilAsync(() =>
Contains(transport.SentBytes.Skip(length).ToArray(), IAC, 250, NAWS, 0, (byte)width, 0, (byte)height, IAC, 240))).IsTrue();
}

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