From 0be1a28869513dfd0a78e0376eee285ee92f9e62 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Fri, 16 Oct 2026 18:02:03 -0500 Subject: [PATCH] Retain pane dimensions until NAWS negotiation completes --- src/SharpMUTerm.Core/Telnet/ITelnetSession.cs | 5 +- src/SharpMUTerm.Core/Telnet/TelnetSession.cs | 32 ++++----- .../Telnet/WindowSizeProtocol.cs | 67 +++++++++++++++++++ .../Telnet/TelnetSessionTests.cs | 64 ++++++++++++++++++ 4 files changed, 152 insertions(+), 16 deletions(-) create mode 100644 src/SharpMUTerm.Core/Telnet/WindowSizeProtocol.cs diff --git a/src/SharpMUTerm.Core/Telnet/ITelnetSession.cs b/src/SharpMUTerm.Core/Telnet/ITelnetSession.cs index 407058be..405f67cc 100644 --- a/src/SharpMUTerm.Core/Telnet/ITelnetSession.cs +++ b/src/SharpMUTerm.Core/Telnet/ITelnetSession.cs @@ -51,7 +51,10 @@ public interface ITelnetSession : IAsyncDisposable /// Sends a GMCP message. ValueTask SendGmcpAsync(string package, string json, CancellationToken cancellationToken = default); - /// Reports the terminal window size to the server via NAWS. + /// + /// 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. + /// ValueTask SetWindowSizeAsync(int width, int height); /// Closes the session. diff --git a/src/SharpMUTerm.Core/Telnet/TelnetSession.cs b/src/SharpMUTerm.Core/Telnet/TelnetSession.cs index cd289a25..6a449115 100644 --- a/src/SharpMUTerm.Core/Telnet/TelnetSession.cs +++ b/src/SharpMUTerm.Core/Telnet/TelnetSession.cs @@ -399,21 +399,24 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) private Task 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().OnGMCPMessage(OnGmcpAsync) + .AddPlugin().OnMSDPMessage(OnMsdpAsync) + .AddPlugin().OnMSSP(OnMsspAsync).WithMSSPConfig(static () => new MSSPConfig()) + .AddPlugin() + .AddPlugin().WithCharsetOrder(_options.CharsetOrder) + .AddPlugin().OnPrompt(OnPromptAsync) + .AddPlugin().OnPrompt(OnPromptAsync) + .AddPlugin().OnPrompt(OnPromptAsync) + .AddPlugin().OnMXPEnabled(OnMxpEnabledAsync) + .AddPlugin().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, @@ -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()?.SendWindowSizeAsync(width, height) - ?? ValueTask.CompletedTask; + var protocol = interpreter.PluginManager?.GetPlugin() as WindowSizeProtocol + ?? throw new InvalidOperationException("Window size protocol is not configured."); + return protocol.ReportAsync(width, height); } public async Task DisconnectAsync() diff --git a/src/SharpMUTerm.Core/Telnet/WindowSizeProtocol.cs b/src/SharpMUTerm.Core/Telnet/WindowSizeProtocol.cs new file mode 100644 index 00000000..5470b5ea --- /dev/null +++ b/src/SharpMUTerm.Core/Telnet/WindowSizeProtocol.cs @@ -0,0 +1,67 @@ +using TelnetNegotiationCore.Protocols; + +namespace SharpMUTerm.Core.Telnet; + +/// +/// 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. +/// +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; + } +} diff --git a/tests/SharpMUTerm.Core.Tests/Telnet/TelnetSessionTests.cs b/tests/SharpMUTerm.Core.Tests/Telnet/TelnetSessionTests.cs index 22c54dbd..816589e6 100644 --- a/tests/SharpMUTerm.Core.Tests/Telnet/TelnetSessionTests.cs +++ b/tests/SharpMUTerm.Core.Tests/Telnet/TelnetSessionTests.cs @@ -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; @@ -92,6 +93,69 @@ private static async Task WaitUntilAsync(Func 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() {