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()
{