From cf3ee3f667300fd66ec9ab2c8cae90ed8a03d642 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 25 Sep 2026 18:17:50 -0400 Subject: [PATCH 1/2] Fix TermControl engine and PTY resize synchronization Discard stale pre-start layout resizes, flush deferred resizes after startup and restart, and cover the lifecycle with headless regression tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Devolutions.Terminal.Control/PACKAGE.md | 7 + .../TermControl.cs | 80 +++- .../TermControlResizeTests.cs | 355 ++++++++++++++++++ 3 files changed, 430 insertions(+), 12 deletions(-) create mode 100644 tests/Devolutions.Terminal.Control.Tests/TermControlResizeTests.cs diff --git a/src/Devolutions.Terminal.Control/PACKAGE.md b/src/Devolutions.Terminal.Control/PACKAGE.md index fea5732..a9fda0c 100644 --- a/src/Devolutions.Terminal.Control/PACKAGE.md +++ b/src/Devolutions.Terminal.Control/PACKAGE.md @@ -29,6 +29,13 @@ Content = terminal; await terminal.StartAsync(new ProfileSettings(), columns: 120, rows: 30); ``` +The `columns` and `rows` passed to `StartAsync` set both the engine and PTY +initially, even if the control was arranged at a different size before startup. +Subsequent layout passes resize both to fit the available space. You can use +`TermControl.MeasureCell(profile, displayScale)` to calculate an initial grid +for the profile and display; pending layout changes made while the connection +starts are applied once it is ready. + See the [`samples/Devolutions.Terminal.Control.Sample`](https://github.com/Devolutions/devolutions-terminal/tree/main/samples/Devolutions.Terminal.Control.Sample) project in the [Devolutions Terminal repository](https://github.com/Devolutions/devolutions-terminal) for a full working app, plus the full source and documentation. diff --git a/src/Devolutions.Terminal.Control/TermControl.cs b/src/Devolutions.Terminal.Control/TermControl.cs index 547e6c7..233bdf4 100644 --- a/src/Devolutions.Terminal.Control/TermControl.cs +++ b/src/Devolutions.Terminal.Control/TermControl.cs @@ -84,6 +84,8 @@ public sealed class TermControl : Avalonia.Controls.Control private int _pendingPtyRows; private int _pendingPtyPixelWidth; private int _pendingPtyPixelHeight; + private bool _hasPendingPtyResize; + private bool _connectionStarting; // Throughput-harness diagnostics (Devolutions.Terminal.Bench): posts requested by // the engine-invalidated handler vs UI drains actually executed. @@ -275,6 +277,7 @@ public static CellSize MeasureCell(ProfileSettings profile, double scale = 1) public async Task StartAsync(ProfileSettings profile, int columns, int rows) { + ClearPendingPtyResize(); Profile = profile; _padding = ParsePadding(profile.Padding); _shaderEffectsEnabled = true; @@ -291,7 +294,22 @@ public async Task StartAsync(ProfileSettings profile, int columns, int rows) profile.AllowKittyKeyboardMode); ResizeEngine(columns, rows); - await StartConnectionAsync(profile, columns, rows).ConfigureAwait(true); + _connectionStarting = true; + try + { + await StartConnectionAsync(profile, columns, rows).ConfigureAwait(true); + } + catch + { + ClearPendingPtyResize(); + throw; + } + finally + { + _connectionStarting = false; + } + + FlushPtyResize(); _blinkTimer.Start(); InvalidateVisual(); } @@ -378,15 +396,40 @@ public async Task RestartAsync(CancellationToken cancellationToken = default) { var connection = _connection ?? throw new InvalidOperationException("The terminal connection has not been started."); - await connection.CloseAsync(cancellationToken).ConfigureAwait(true); - ResetTerminal(); - await connection.RestartAsync(cancellationToken: cancellationToken).ConfigureAwait(true); + _connectionStarting = true; + try + { + await connection.CloseAsync(cancellationToken).ConfigureAwait(true); + ResetTerminal(); + await connection.RestartAsync(cancellationToken: cancellationToken).ConfigureAwait(true); + } + catch + { + ClearPendingPtyResize(); + throw; + } + finally + { + _connectionStarting = false; + } + + if (!_hasPendingPtyResize) + { + SchedulePtyResize( + Engine.Columns, + Engine.Rows, + checked((int)Math.Max(1, Engine.Columns * _engineCellWidthPixels)), + checked((int)Math.Max(1, Engine.Rows * _engineCellHeightPixels))); + } + + FlushPtyResize(); _blinkTimer.Start(); } public async Task CloseAsync() { _blinkTimer.Stop(); + ClearPendingPtyResize(); if (_connection is not null) { var connection = _connection; @@ -1007,28 +1050,41 @@ private void SchedulePtyResize(int columns, int rows, int pixelWidth, int pixelH _pendingPtyRows = rows; _pendingPtyPixelWidth = pixelWidth; _pendingPtyPixelHeight = pixelHeight; + _hasPendingPtyResize = true; _ptyResizeTimer.Stop(); _ptyResizeTimer.Start(); } + private void ClearPendingPtyResize() + { + _ptyResizeTimer.Stop(); + _hasPendingPtyResize = false; + } + private void FlushPtyResize() { _ptyResizeTimer.Stop(); + if (!_hasPendingPtyResize || _connectionStarting || _connection?.IsRunning != true) + { + return; + } + try { - if (_connection?.IsRunning == true) - { - _connection.Resize( - _pendingPtyColumns, - _pendingPtyRows, - _pendingPtyPixelWidth, - _pendingPtyPixelHeight); - } + _connection.Resize( + _pendingPtyColumns, + _pendingPtyRows, + _pendingPtyPixelWidth, + _pendingPtyPixelHeight); } catch (Exception ex) when (ex is IOException or InvalidOperationException or OverflowException) { ReportInteractionError("Terminal resize was not accepted; resize again after input drains", ex); } + finally + { + _hasPendingPtyResize = false; + } } private void ResizeEngine(int columns, int rows) diff --git a/tests/Devolutions.Terminal.Control.Tests/TermControlResizeTests.cs b/tests/Devolutions.Terminal.Control.Tests/TermControlResizeTests.cs new file mode 100644 index 0000000..a621dad --- /dev/null +++ b/tests/Devolutions.Terminal.Control.Tests/TermControlResizeTests.cs @@ -0,0 +1,355 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Threading; +using Devolutions.Terminal.Connection; +using Devolutions.Terminal.Settings; +using Xunit; + +namespace Devolutions.Terminal.Control.Tests; + +public sealed class TermControlResizeTests +{ + [AvaloniaFact] + public async Task StartAsyncDiscardsResizeFromBeforeStart() + { + var connection = new FakePtyConnection(); + var (window, control) = CreateWindow(connection); + try + { + window.Show(); + Assert.True(control.Bounds.Width > 0); + var arrangedColumns = control.Engine.Columns; + var arrangedRows = control.Engine.Rows; + + await control.StartAsync(new ProfileSettings { Commandline = "cmd.exe" }, 62, 19); + Assert.True(arrangedColumns != 62 || arrangedRows != 19); + await Task.Delay(80); + Dispatcher.UIThread.RunJobs(); + + Assert.Equal(62, control.Engine.Columns); + Assert.Equal(19, control.Engine.Rows); + Assert.Equal(62, connection.Columns); + Assert.Equal(19, connection.Rows); + Assert.Equal(0, connection.ResizeCount); + } + finally + { + await control.CloseAsync(); + window.Close(); + } + } + + [AvaloniaFact] + public async Task LayoutDuringStartFlushesLatestResizeAfterConnectionStarts() + { + var connection = new FakePtyConnection { StartGate = NewGate() }; + var (window, control) = CreateWindow(connection); + try + { + window.Show(); + var start = control.StartAsync(new ProfileSettings { Commandline = "cmd.exe" }, 62, 19); + await connection.StartEntered.Task; + + window.Width += 120; + Dispatcher.UIThread.RunJobs(); + var firstColumns = control.Engine.Columns; + window.Width += 120; + window.Height += 100; + Dispatcher.UIThread.RunJobs(); + var expectedColumns = control.Engine.Columns; + var expectedRows = control.Engine.Rows; + Assert.NotEqual(firstColumns, expectedColumns); + + await Task.Delay(80); + Dispatcher.UIThread.RunJobs(); + Assert.Equal(0, connection.ResizeCount); + + connection.StartGate.SetResult(); + await start; + + Assert.Equal(expectedColumns, control.Engine.Columns); + Assert.Equal(expectedRows, control.Engine.Rows); + Assert.Equal(expectedColumns, connection.Columns); + Assert.Equal(expectedRows, connection.Rows); + Assert.Equal(1, connection.ResizeCount); + Assert.Equal(expectedColumns * CellPixelWidth(control), connection.PixelWidth); + Assert.Equal(expectedRows * CellPixelHeight(control), connection.PixelHeight); + } + finally + { + await control.CloseAsync(); + window.Close(); + } + } + + [AvaloniaFact] + public async Task LayoutDuringRestartFlushesLatestResizeAfterConnectionRestarts() + { + var connection = new FakePtyConnection { RestartGate = NewGate() }; + var (window, control) = CreateWindow(connection); + try + { + window.Show(); + await control.StartAsync(new ProfileSettings { Commandline = "cmd.exe" }, 62, 19); + var restart = control.RestartAsync(); + await connection.RestartEntered.Task; + + window.Width += 240; + window.Height += 100; + Dispatcher.UIThread.RunJobs(); + var expectedColumns = control.Engine.Columns; + var expectedRows = control.Engine.Rows; + Assert.True(expectedColumns != 62 || expectedRows != 19); + + await Task.Delay(80); + Dispatcher.UIThread.RunJobs(); + Assert.Equal(0, connection.ResizeCount); + + connection.RestartGate.SetResult(); + await restart; + + Assert.Equal(expectedColumns, connection.Columns); + Assert.Equal(expectedRows, connection.Rows); + Assert.Equal(1, connection.ResizeCount); + Assert.Equal(expectedColumns * CellPixelWidth(control), connection.PixelWidth); + Assert.Equal(expectedRows * CellPixelHeight(control), connection.PixelHeight); + } + finally + { + await control.CloseAsync(); + window.Close(); + } + } + + [AvaloniaFact] + public async Task RestartRestoresCurrentGridAfterConnectionReusesLaunchSize() + { + var connection = new FakePtyConnection(); + var (window, control) = CreateWindow(connection); + try + { + window.Show(); + await control.StartAsync(new ProfileSettings { Commandline = "cmd.exe" }, 62, 19); + + window.Width += 240; + Dispatcher.UIThread.RunJobs(); + var expectedColumns = control.Engine.Columns; + Assert.NotEqual(62, expectedColumns); + await Task.Delay(80); + Dispatcher.UIThread.RunJobs(); + Assert.Equal(expectedColumns, connection.Columns); + var resizeCount = connection.ResizeCount; + + await control.RestartAsync(); + + Assert.Equal(expectedColumns, control.Engine.Columns); + Assert.Equal(expectedColumns, connection.Columns); + Assert.Equal(control.Engine.Rows, connection.Rows); + Assert.Equal(resizeCount + 1, connection.ResizeCount); + } + finally + { + await control.CloseAsync(); + window.Close(); + } + } + + [AvaloniaFact] + public async Task FailedStartDiscardsPendingResize() + { + var connection = new FakePtyConnection + { + StartGate = NewGate(), + StartFailure = new InvalidOperationException("start failed"), + }; + var (window, control) = CreateWindow(connection); + try + { + window.Show(); + var start = control.StartAsync(new ProfileSettings { Commandline = "cmd.exe" }, 62, 19); + await connection.StartEntered.Task; + + window.Width += 240; + Dispatcher.UIThread.RunJobs(); + Assert.NotEqual(62, control.Engine.Columns); + + await Task.Delay(80); + Dispatcher.UIThread.RunJobs(); + connection.StartGate.SetResult(); + await Assert.ThrowsAsync(() => start); + await Task.Delay(80); + Dispatcher.UIThread.RunJobs(); + + Assert.Equal(0, connection.ResizeCount); + Assert.True(connection.Disposed); + } + finally + { + await control.CloseAsync(); + window.Close(); + } + } + + [AvaloniaFact] + public async Task CloseCancelsPendingResize() + { + var connection = new FakePtyConnection(); + var (window, control) = CreateWindow(connection); + try + { + window.Show(); + await control.StartAsync(new ProfileSettings { Commandline = "cmd.exe" }, 62, 19); + window.Width += 240; + Dispatcher.UIThread.RunJobs(); + Assert.NotEqual(62, control.Engine.Columns); + + await control.CloseAsync(); + await Task.Delay(80); + Dispatcher.UIThread.RunJobs(); + + Assert.Equal(0, connection.ResizeCount); + Assert.True(connection.Disposed); + } + finally + { + window.Close(); + } + } + + private static TaskCompletionSource NewGate() => + new(TaskCreationOptions.RunContinuationsAsynchronously); + + private static int CellPixelWidth(TermControl control) => + checked((int)Math.Max(1, Math.Round(control.CellSize.Width))); + + private static int CellPixelHeight(TermControl control) => + checked((int)Math.Max(1, Math.Round(control.CellSize.Height))); + + private static (Window Window, TermControl Control) CreateWindow(FakePtyConnection connection) + { + var control = new TermControl { ConnectionFactory = _ => connection }; + return (new Window { Width = 800, Height = 600, Content = control }, control); + } + + private sealed class FakePtyConnection : IRestartableTerminalConnection + { +#pragma warning disable CS0067 + public event EventHandler>? OutputReceived; + public event EventHandler? Exited; + public event EventHandler? Faulted; + public event EventHandler? SessionExited; +#pragma warning restore CS0067 + + private TerminalLaunchOptions? _launchOptions; + + public TaskCompletionSource? StartGate { get; init; } + public TaskCompletionSource? RestartGate { get; init; } + public TaskCompletionSource StartEntered { get; } = NewGate(); + public TaskCompletionSource RestartEntered { get; } = NewGate(); + public Exception? StartFailure { get; init; } + public bool Disposed { get; private set; } + public bool IsRunning { get; private set; } + public int Columns { get; private set; } + public int Rows { get; private set; } + public int PixelWidth { get; private set; } + public int PixelHeight { get; private set; } + public int ResizeCount { get; private set; } + public TerminalConnectionCapabilities Capabilities => + TerminalConnectionCapabilities.Resize | TerminalConnectionCapabilities.Restart; + public TerminalConnectionState State { get; private set; } + public TerminalProcessMetadata? ProcessMetadata => null; + public TerminalExitInfo? LastExitInfo => null; + + public async Task StartAsync( + TerminalLaunchOptions options, + CancellationToken cancellationToken = default) + { + StartEntered.TrySetResult(); + if (StartGate is not null) + { + await StartGate.Task; + } + + if (StartFailure is not null) + { + throw StartFailure; + } + + _launchOptions = options; + Columns = options.Columns; + Rows = options.Rows; + IsRunning = true; + State = TerminalConnectionState.Connected; + } + + public Task StartAsync( + string commandLine, + string? workingDirectory, + int columns, + int rows, + CancellationToken cancellationToken = default) => + StartAsync( + new TerminalLaunchOptions + { + CommandLine = commandLine, + WorkingDirectory = workingDirectory, + Columns = columns, + Rows = rows, + }, + cancellationToken); + + public void Write(ReadOnlySpan data) { } + public void Write(string text) { } + public ValueTask WriteAsync( + ReadOnlyMemory data, + CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + + public void Resize(int columns, int rows) => Resize(columns, rows, 0, 0); + + public void Resize(int columns, int rows, int pixelWidth, int pixelHeight) + { + Assert.True(IsRunning); + Columns = columns; + Rows = rows; + PixelWidth = pixelWidth; + PixelHeight = pixelHeight; + ResizeCount++; + } + + public async Task RestartAsync( + TerminalLaunchOptions? options = null, + CancellationToken cancellationToken = default) + { + RestartEntered.TrySetResult(); + if (RestartGate is not null) + { + await RestartGate.Task; + } + + var launch = options ?? _launchOptions + ?? throw new InvalidOperationException("No prior launch."); + Columns = launch.Columns; + Rows = launch.Rows; + PixelWidth = 0; + PixelHeight = 0; + IsRunning = true; + State = TerminalConnectionState.Connected; + } + + public Task CloseAsync(CancellationToken cancellationToken = default) + { + IsRunning = false; + State = TerminalConnectionState.Closed; + return Task.CompletedTask; + } + + public ValueTask DisposeAsync() + { + Disposed = true; + IsRunning = false; + State = TerminalConnectionState.Disposed; + return ValueTask.CompletedTask; + } + } +} From 2e86341ed6cec2f2d8548756b9b59efa3c218981 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 25 Sep 2026 18:56:06 -0400 Subject: [PATCH 2/2] Stabilize PTY resize regressions across headless platforms Use explicit arrange passes instead of window-driven layout so startup and close timing assertions remain deterministic on CI runners. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../TermControlResizeTests.cs | 64 ++++++++----------- 1 file changed, 28 insertions(+), 36 deletions(-) diff --git a/tests/Devolutions.Terminal.Control.Tests/TermControlResizeTests.cs b/tests/Devolutions.Terminal.Control.Tests/TermControlResizeTests.cs index a621dad..4852f19 100644 --- a/tests/Devolutions.Terminal.Control.Tests/TermControlResizeTests.cs +++ b/tests/Devolutions.Terminal.Control.Tests/TermControlResizeTests.cs @@ -1,5 +1,4 @@ using Avalonia; -using Avalonia.Controls; using Avalonia.Headless.XUnit; using Avalonia.Threading; using Devolutions.Terminal.Connection; @@ -14,10 +13,10 @@ public sealed class TermControlResizeTests public async Task StartAsyncDiscardsResizeFromBeforeStart() { var connection = new FakePtyConnection(); - var (window, control) = CreateWindow(connection); + var control = CreateControl(connection); try { - window.Show(); + ArrangeControl(control, 800, 600); Assert.True(control.Bounds.Width > 0); var arrangedColumns = control.Engine.Columns; var arrangedRows = control.Engine.Rows; @@ -36,7 +35,6 @@ public async Task StartAsyncDiscardsResizeFromBeforeStart() finally { await control.CloseAsync(); - window.Close(); } } @@ -44,19 +42,16 @@ public async Task StartAsyncDiscardsResizeFromBeforeStart() public async Task LayoutDuringStartFlushesLatestResizeAfterConnectionStarts() { var connection = new FakePtyConnection { StartGate = NewGate() }; - var (window, control) = CreateWindow(connection); + var control = CreateControl(connection); try { - window.Show(); + ArrangeControl(control, 800, 600); var start = control.StartAsync(new ProfileSettings { Commandline = "cmd.exe" }, 62, 19); await connection.StartEntered.Task; - window.Width += 120; - Dispatcher.UIThread.RunJobs(); + ArrangeControl(control, 920, 600); var firstColumns = control.Engine.Columns; - window.Width += 120; - window.Height += 100; - Dispatcher.UIThread.RunJobs(); + ArrangeControl(control, 1040, 700); var expectedColumns = control.Engine.Columns; var expectedRows = control.Engine.Rows; Assert.NotEqual(firstColumns, expectedColumns); @@ -79,7 +74,6 @@ public async Task LayoutDuringStartFlushesLatestResizeAfterConnectionStarts() finally { await control.CloseAsync(); - window.Close(); } } @@ -87,17 +81,15 @@ public async Task LayoutDuringStartFlushesLatestResizeAfterConnectionStarts() public async Task LayoutDuringRestartFlushesLatestResizeAfterConnectionRestarts() { var connection = new FakePtyConnection { RestartGate = NewGate() }; - var (window, control) = CreateWindow(connection); + var control = CreateControl(connection); try { - window.Show(); + ArrangeControl(control, 800, 600); await control.StartAsync(new ProfileSettings { Commandline = "cmd.exe" }, 62, 19); var restart = control.RestartAsync(); await connection.RestartEntered.Task; - window.Width += 240; - window.Height += 100; - Dispatcher.UIThread.RunJobs(); + ArrangeControl(control, 1040, 700); var expectedColumns = control.Engine.Columns; var expectedRows = control.Engine.Rows; Assert.True(expectedColumns != 62 || expectedRows != 19); @@ -118,7 +110,6 @@ public async Task LayoutDuringRestartFlushesLatestResizeAfterConnectionRestarts( finally { await control.CloseAsync(); - window.Close(); } } @@ -126,14 +117,13 @@ public async Task LayoutDuringRestartFlushesLatestResizeAfterConnectionRestarts( public async Task RestartRestoresCurrentGridAfterConnectionReusesLaunchSize() { var connection = new FakePtyConnection(); - var (window, control) = CreateWindow(connection); + var control = CreateControl(connection); try { - window.Show(); + ArrangeControl(control, 800, 600); await control.StartAsync(new ProfileSettings { Commandline = "cmd.exe" }, 62, 19); - window.Width += 240; - Dispatcher.UIThread.RunJobs(); + ArrangeControl(control, 1040, 600); var expectedColumns = control.Engine.Columns; Assert.NotEqual(62, expectedColumns); await Task.Delay(80); @@ -151,7 +141,6 @@ public async Task RestartRestoresCurrentGridAfterConnectionReusesLaunchSize() finally { await control.CloseAsync(); - window.Close(); } } @@ -163,15 +152,14 @@ public async Task FailedStartDiscardsPendingResize() StartGate = NewGate(), StartFailure = new InvalidOperationException("start failed"), }; - var (window, control) = CreateWindow(connection); + var control = CreateControl(connection); try { - window.Show(); + ArrangeControl(control, 800, 600); var start = control.StartAsync(new ProfileSettings { Commandline = "cmd.exe" }, 62, 19); await connection.StartEntered.Task; - window.Width += 240; - Dispatcher.UIThread.RunJobs(); + ArrangeControl(control, 1040, 600); Assert.NotEqual(62, control.Engine.Columns); await Task.Delay(80); @@ -187,7 +175,6 @@ public async Task FailedStartDiscardsPendingResize() finally { await control.CloseAsync(); - window.Close(); } } @@ -195,13 +182,12 @@ public async Task FailedStartDiscardsPendingResize() public async Task CloseCancelsPendingResize() { var connection = new FakePtyConnection(); - var (window, control) = CreateWindow(connection); + var control = CreateControl(connection); try { - window.Show(); + ArrangeControl(control, 800, 600); await control.StartAsync(new ProfileSettings { Commandline = "cmd.exe" }, 62, 19); - window.Width += 240; - Dispatcher.UIThread.RunJobs(); + ArrangeControl(control, 1040, 600); Assert.NotEqual(62, control.Engine.Columns); await control.CloseAsync(); @@ -213,7 +199,10 @@ public async Task CloseCancelsPendingResize() } finally { - window.Close(); + if (!connection.Disposed) + { + await control.CloseAsync(); + } } } @@ -226,10 +215,13 @@ private static int CellPixelWidth(TermControl control) => private static int CellPixelHeight(TermControl control) => checked((int)Math.Max(1, Math.Round(control.CellSize.Height))); - private static (Window Window, TermControl Control) CreateWindow(FakePtyConnection connection) + private static TermControl CreateControl(FakePtyConnection connection) => + new() { ConnectionFactory = _ => connection }; + + private static void ArrangeControl(TermControl control, double width, double height) { - var control = new TermControl { ConnectionFactory = _ => connection }; - return (new Window { Width = 800, Height = 600, Content = control }, control); + control.Measure(new Size(width, height)); + control.Arrange(new Rect(0, 0, width, height)); } private sealed class FakePtyConnection : IRestartableTerminalConnection