fix(interactive): correct ConPTY terminal size + reduce lag

Sizing: the pty was spawned at the stale 80x24 default because terminal.Cols/Rows
were read before any layout pass; and a resize during the spawn await was missed.
Force UpdateLayout() before reading the size, subscribe Resized before spawn,
resync once after, and add a LayoutUpdated-driven resync (deduped) as a safety net.
Lag: the read loop awaited a UI-thread dispatch per chunk, serializing pipe reads
behind rendering; switch to Dispatcher.Post (FIFO preserved, no backpressure).
This commit is contained in:
mika kuns
2026-07-23 16:47:15 +02:00
parent 25922a2768
commit bb62740ac8
+51 -6
View File
@@ -41,6 +41,8 @@ public sealed class PtyTerminalSession : IDisposable
private CancellationTokenSource? _readCts; private CancellationTokenSource? _readCts;
private readonly object _writeLock = new(); private readonly object _writeLock = new();
private bool _disposed; private bool _disposed;
private int _lastSyncedCols;
private int _lastSyncedRows;
public bool IsRunning { get; private set; } public bool IsRunning { get; private set; }
public int? ExitCode { get; private set; } public int? ExitCode { get; private set; }
@@ -62,6 +64,19 @@ public sealed class PtyTerminalSession : IDisposable
_terminal = terminal; _terminal = terminal;
_control = control; _control = control;
// ApplyTemplate() only builds the control's visual tree — it does not run a layout
// pass, so terminal.Cols/Rows would still be the TerminalOptions defaults (80x24) here,
// not the pane's real size. Force a synchronous layout pass so TerminalView.ArrangeOverride
// runs and sets terminal.Cols/Rows from the control's actual Bounds before we read them
// for the child process's initial size.
control.UpdateLayout();
// Subscribe before spawning (not after) so a resize that lands while SpawnAsync is still
// being awaited (e.g. the surrounding layout settling further) isn't missed.
terminal.DataReceived += OnTerminalDataReceived;
terminal.Resized += OnTerminalResized;
control.LayoutUpdated += OnControlLayoutUpdated;
var options = new PtyOptions var options = new PtyOptions
{ {
Name = "xterm-256color", Name = "xterm-256color",
@@ -75,11 +90,13 @@ public sealed class PtyTerminalSession : IDisposable
_connection = await PtyProvider.SpawnAsync(options, ct).ConfigureAwait(false); _connection = await PtyProvider.SpawnAsync(options, ct).ConfigureAwait(false);
IsRunning = true; IsRunning = true;
terminal.DataReceived += OnTerminalDataReceived;
terminal.Resized += OnTerminalResized;
_connection.ProcessExited += OnConnectionProcessExited; _connection.ProcessExited += OnConnectionProcessExited;
// The control may have been (re)arranged while the spawn above was in flight; resync once
// now so the pty always starts at the terminal's current, real size rather than whatever
// was captured in PtyOptions.
SyncPtySize(terminal.Cols, terminal.Rows);
_readCts = new CancellationTokenSource(); _readCts = new CancellationTokenSource();
_ = ReadLoopAsync(_connection, terminal, _readCts.Token); _ = ReadLoopAsync(_connection, terminal, _readCts.Token);
@@ -193,7 +210,16 @@ public sealed class PtyTerminalSession : IDisposable
if (charCount <= 0) continue; if (charCount <= 0) continue;
var text = new string(chars, 0, charCount); var text = new string(chars, 0, charCount);
await Dispatcher.UIThread.InvokeAsync(() => terminal.Write(text)); // Post (not InvokeAsync) so this loop doesn't block on a UI-thread round trip
// per chunk — awaiting each dispatch here serialized pipe reads behind UI-thread
// catch-up and was the dominant source of input/output lag. Dispatcher.Post
// preserves FIFO order, so chunks still land in the order they were read; no
// reordering or loss, just no backpressure from the UI thread onto the pipe read.
Dispatcher.UIThread.Post(() =>
{
try { terminal.Write(text); }
catch (Exception) { /* terminal may have been torn down mid-flight */ }
});
} }
} }
catch (OperationCanceledException) { } catch (OperationCanceledException) { }
@@ -220,9 +246,27 @@ public sealed class PtyTerminalSession : IDisposable
} }
} }
private void OnTerminalResized(object? sender, TerminalEvents.ResizeEventArgs e) private void OnTerminalResized(object? sender, TerminalEvents.ResizeEventArgs e) => SyncPtySize(e.Cols, e.Rows);
// Layoutable.LayoutUpdated fires after every layout pass in the window (not just when this
// control's own bounds change), so it's a reliable, level-triggered safety net: whatever
// TerminalView.ArrangeOverride last computed for terminal.Cols/Rows, make sure the pty agrees.
// This covers races around the initial spawn where a Resized event could otherwise be missed.
private void OnControlLayoutUpdated(object? sender, EventArgs e)
{ {
try { _connection?.Resize(e.Cols, e.Rows); } var terminal = _terminal;
if (terminal is null) return;
SyncPtySize(terminal.Cols, terminal.Rows);
}
private void SyncPtySize(int cols, int rows)
{
var connection = _connection;
if (connection is null) return;
if (cols == _lastSyncedCols && rows == _lastSyncedRows) return;
_lastSyncedCols = cols;
_lastSyncedRows = rows;
try { connection.Resize(cols, rows); }
catch (Exception) { /* pty may already have exited */ } catch (Exception) { /* pty may already have exited */ }
} }
@@ -254,6 +298,7 @@ public sealed class PtyTerminalSession : IDisposable
{ {
_control.RemoveHandler(InputElement.KeyDownEvent, OnControlKeyDown); _control.RemoveHandler(InputElement.KeyDownEvent, OnControlKeyDown);
_control.RemoveHandler(InputElement.TextInputEvent, OnControlTextInput); _control.RemoveHandler(InputElement.TextInputEvent, OnControlTextInput);
_control.LayoutUpdated -= OnControlLayoutUpdated;
} }
if (_connection is not null) if (_connection is not null)
{ {