From fa3a46191bb1d536edc1ce921ab09a1cfdbfefa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Mon, 7 Sep 2026 17:06:28 -0400 Subject: [PATCH 1/2] Fix terminal confirmations, input responsiveness, and broker retention Wire safe asynchronous paste and close confirmation, bound ordered PTY input and broker request admission, make the Unix relay nonblocking, and clarify unsupported settings. Extract window lifecycle and confirmation components with behavioral regression coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 34 ++++ native/linux-pty/dt-pty-host.c | 177 ++++++++++-------- .../Actions/CloseConfirmationPolicy.cs | 14 ++ src/Devolutions.Terminal.App/AssemblyInfo.cs | 1 + .../Views/MainWindow.Lifecycle.cs | 113 +++++++++++ .../Views/MainWindow.axaml.cs | 118 +++++------- .../Views/TerminalConfirmationDialog.cs | 58 ++++++ .../AssemblyInfo.cs | 3 + src/Devolutions.Terminal.Broker/BrokerHost.cs | 46 ++--- .../BrokerResponseCache.cs | 67 +++++++ .../AssemblyInfo.cs | 3 + .../ConPtyConnection.cs | 73 +++----- .../LinuxPtyConnection.cs | 134 +++++++------ .../OrderedInputWriter.cs | 167 +++++++++++++++++ .../TermControl.cs | 98 ++++++++-- .../TerminalInteractionContracts.cs | 1 + .../SettingsView.axaml | 16 +- src/Devolutions.Terminal/App.axaml.cs | 8 +- .../CloseConfirmationPolicyTests.cs | 25 +++ .../BrokerResponseCacheTests.cs | 63 +++++++ .../ConnectionContractTests.cs | 17 ++ .../LinuxPtyConnectionTests.cs | 92 +++++++++ .../OrderedInputWriterTests.cs | 174 +++++++++++++++++ .../TermControlAccessibilityTests.cs | 2 +- .../TermControlPasteTests.cs | 88 +++++++++ .../SettingsEditorViewModelTests.cs | 32 ++++ .../TerminalConfirmationDialogTests.cs | 62 ++++++ 27 files changed, 1378 insertions(+), 308 deletions(-) create mode 100644 src/Devolutions.Terminal.App/Actions/CloseConfirmationPolicy.cs create mode 100644 src/Devolutions.Terminal.App/Views/MainWindow.Lifecycle.cs create mode 100644 src/Devolutions.Terminal.App/Views/TerminalConfirmationDialog.cs create mode 100644 src/Devolutions.Terminal.Broker/AssemblyInfo.cs create mode 100644 src/Devolutions.Terminal.Broker/BrokerResponseCache.cs create mode 100644 src/Devolutions.Terminal.Connection/AssemblyInfo.cs create mode 100644 src/Devolutions.Terminal.Connection/OrderedInputWriter.cs create mode 100644 tests/Devolutions.Terminal.App.Tests/CloseConfirmationPolicyTests.cs create mode 100644 tests/Devolutions.Terminal.Broker.Tests/BrokerResponseCacheTests.cs create mode 100644 tests/Devolutions.Terminal.Connection.Tests/OrderedInputWriterTests.cs create mode 100644 tests/Devolutions.Terminal.UI.Tests/TerminalConfirmationDialogTests.cs diff --git a/README.md b/README.md index 8a66cf2..4200da0 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,40 @@ Build and release gates are documented in [docs/release.md](docs/release.md). ## Compatibility inventory +### Safety and compatibility settings + +Large or multi-line pastes requiring confirmation are cancelled unless explicitly +approved. The application prompts asynchronously; closing the prompt cancels the +paste. Embedded controls without a confirmation handler also cancel warned pastes. + +`warning.confirmOnClose` applies to user-initiated window, tab, pane, and bulk +close actions. `never` skips confirmation; `always` confirms closing any running +session; `automatic` confirms when an action closes more than one running session +(the legacy `confirmCloseAllTabs` behavior). Already-exited sessions and automatic +process-exit cleanup never prompt. + +PTY input is queued in order off the UI thread, with limits of 256 pending writes +and 4 MiB (including framing on Unix). Overflow rejects the entire new write and +reports an error rather than blocking or silently dropping input. Async writes +complete after transport delivery; caller cancellation skips writes not yet +started. Cancelling an in-flight write terminates its session because input may +have been partially delivered and Unix framing cannot safely resume. Closing a +blocked Unix session has a one-second grace period before host termination; +undelivered input is reported. + +The editor disables options that are currently retained only for settings-file +compatibility: `compatibility.textMeasurement`, `compatibility.ambiguousWidth`, +`experimental.detectURLs`, and `disableAnimations`. The terminal engine determines +text measurement and character widths. Plain-text URL detection is not implemented; +explicit OSC 8 hyperlinks remain supported. Window/pane animation effects are not +configurable through `disableAnimations`. + +Broker retries share active requests and retain completed responses for at least +five seconds after completion. Admission is bounded at 128 active requests and +1024 total retained requests. When full, new requests receive an explicit +unavailable response without executing their action; existing retries still join +their original operation. + The port tracks Windows Terminal settings, actions, VT dispatch, command line, and settings-page surfaces in [`compat/windows-terminal.json`](compat/windows-terminal.json). Tests use that diff --git a/native/linux-pty/dt-pty-host.c b/native/linux-pty/dt-pty-host.c index 1dd4e95..feafd28 100644 --- a/native/linux-pty/dt-pty-host.c +++ b/native/linux-pty/dt-pty-host.c @@ -3,6 +3,7 @@ #endif #include +#include #include #if defined(__APPLE__) #include @@ -20,60 +21,13 @@ #include #include -static bool write_all(int fd, const void *buffer, size_t length) { - const uint8_t *bytes = buffer; - while (length > 0) { - ssize_t written = write(fd, bytes, length); - if (written < 0) { - if (errno == EINTR) continue; - return false; - } - - bytes += written; - length -= (size_t)written; - } - - return true; +static bool nonblocking(int fd) { + int flags = fcntl(fd, F_GETFL); + return flags >= 0 && fcntl(fd, F_SETFL, flags | O_NONBLOCK) == 0; } -static bool read_all(int fd, void *buffer, size_t length) { - uint8_t *bytes = buffer; - while (length > 0) { - ssize_t count = read(fd, bytes, length); - if (count == 0) return false; - if (count < 0) { - if (errno == EINTR) continue; - return false; - } - - bytes += count; - length -= (size_t)count; - } - - return true; -} - -static bool read_header(char *buffer, size_t capacity) { - size_t length = 0; - while (length + 1 < capacity) { - char value; - ssize_t count = read(STDIN_FILENO, &value, 1); - if (count == 0) return false; - if (count < 0) { - if (errno == EINTR) continue; - return false; - } - - if (value == '\n') { - buffer[length] = '\0'; - return true; - } - - buffer[length++] = value; - } - - errno = EMSGSIZE; - return false; +static bool retryable(void) { + return errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK; } static void resize_pty(int master, pid_t child, unsigned columns, unsigned rows) { @@ -122,54 +76,125 @@ int main(int argc, char **argv) { _exit(127); } + signal(SIGPIPE, SIG_IGN); + if (!nonblocking(master) || !nonblocking(STDIN_FILENO) || !nonblocking(STDOUT_FILENO)) { + perror("nonblocking relay"); + kill(-child, SIGKILL); + close(master); + waitpid(child, NULL, 0); + return 74; + } + bool input_open = true; bool master_open = true; - uint8_t buffer[16384]; - while (master_open) { - struct pollfd descriptors[2] = { - { .fd = master, .events = POLLIN }, - { .fd = input_open ? STDIN_FILENO : -1, .events = POLLIN }, + bool master_hungup = false; + uint8_t input[16384], output[16384]; + size_t input_length = 0, input_offset = 0; + size_t output_length = 0, output_offset = 0; + size_t remaining = 0, header_length = 0; + char header[96]; + bool failed = false; + while (master_open || output_length > 0) { + struct pollfd descriptors[3] = { + { .fd = master_open && (output_length == 0 || (input_length > 0 && !master_hungup)) ? master : -1, + .events = (output_length == 0 ? POLLIN : 0) | (input_length > 0 ? POLLOUT : 0) }, + { .fd = input_open && input_length == 0 ? STDIN_FILENO : -1, .events = POLLIN }, + { .fd = output_length > 0 ? STDOUT_FILENO : -1, .events = POLLOUT }, }; - int result = poll(descriptors, 2, -1); + int result = poll(descriptors, 3, -1); if (result < 0) { if (errno == EINTR) continue; perror("poll"); + failed = true; break; } - if (descriptors[0].revents & (POLLIN | POLLHUP)) { - ssize_t count = read(master, buffer, sizeof(buffer)); + if (descriptors[0].revents & (POLLHUP | POLLERR)) master_hungup = true; + + if (output_length == 0 && descriptors[0].revents & (POLLIN | POLLHUP | POLLERR)) { + ssize_t count = read(master, output, sizeof(output)); if (count > 0) { - if (!write_all(STDOUT_FILENO, buffer, (size_t)count)) break; + output_length = (size_t)count; + output_offset = 0; } else if (count == 0 || errno == EIO) { master_open = false; - } else if (errno != EINTR) { + } else if (!retryable()) { perror("read pty"); + failed = true; + break; + } + } + + if (descriptors[2].revents & (POLLOUT | POLLERR | POLLHUP)) { + ssize_t count = write(STDOUT_FILENO, output + output_offset, output_length); + if (count > 0) { + output_offset += (size_t)count; + output_length -= (size_t)count; + } else if (count == 0 || !retryable()) { + perror("write stdout"); + failed = true; + break; + } + } + + if (master_open && input_length > 0 && descriptors[0].revents & POLLOUT) { + ssize_t count = write(master, input + input_offset, input_length); + if (count > 0) { + input_offset += (size_t)count; + input_length -= (size_t)count; + } else if (count == 0 || !retryable()) { + perror("write pty"); + failed = true; break; } } if (input_open && descriptors[1].revents & (POLLIN | POLLHUP)) { - char header[96]; - if (!read_header(header, sizeof(header))) { + size_t capacity = remaining > 0 + ? (remaining < sizeof(input) ? remaining : sizeof(input)) : 1; + ssize_t count = read(STDIN_FILENO, input, capacity); + if (count == 0) { input_open = false; kill(-child, SIGHUP); continue; } + if (count < 0) { + if (retryable()) continue; + perror("read stdin"); + failed = true; + break; + } + + if (remaining > 0) { + input_length = (size_t)count; + input_offset = 0; + remaining -= (size_t)count; + continue; + } + + if (input[0] != '\n') { + if (header_length + 1 >= sizeof(header)) { + fprintf(stderr, "input header too long\n"); + failed = true; + break; + } + header[header_length++] = (char)input[0]; + continue; + } + + header[header_length] = '\0'; + header_length = 0; if (header[0] == 'D' && header[1] == ' ') { - size_t length = (size_t)strtoull(header + 2, NULL, 10); - while (length > 0) { - size_t chunk = length < sizeof(buffer) ? length : sizeof(buffer); - if (!read_all(STDIN_FILENO, buffer, chunk) || - !write_all(master, buffer, chunk)) { - input_open = false; - kill(-child, SIGHUP); - break; - } - - length -= chunk; + char *end; + errno = 0; + unsigned long length = strtoul(header + 2, &end, 10); + if (errno != 0 || end == header + 2 || *end != '\0' || length > 4 * 1024 * 1024) { + fprintf(stderr, "invalid input frame length\n"); + failed = true; + break; } + remaining = (size_t)length; } else if (header[0] == 'R' && header[1] == ' ') { unsigned new_columns = 0; unsigned new_rows = 0; @@ -185,11 +210,13 @@ int main(int argc, char **argv) { } } + if (failed) kill(-child, SIGKILL); close(master); int status = 0; while (waitpid(child, &status, 0) < 0 && errno == EINTR) { } + if (failed) return 74; if (WIFEXITED(status)) return WEXITSTATUS(status); if (WIFSIGNALED(status)) return 128 + WTERMSIG(status); return 1; diff --git a/src/Devolutions.Terminal.App/Actions/CloseConfirmationPolicy.cs b/src/Devolutions.Terminal.App/Actions/CloseConfirmationPolicy.cs new file mode 100644 index 0000000..a900d7f --- /dev/null +++ b/src/Devolutions.Terminal.App/Actions/CloseConfirmationPolicy.cs @@ -0,0 +1,14 @@ +using Devolutions.Terminal.Settings; + +namespace Devolutions.Terminal.App.Actions; + +public static class CloseConfirmationPolicy +{ + public static bool RequiresConfirmation(ConfirmOnClose policy, int runningSessions, bool automaticExit = false) => + !automaticExit && policy switch + { + ConfirmOnClose.Always => runningSessions > 0, + ConfirmOnClose.Automatic => runningSessions > 1, + _ => false, + }; +} diff --git a/src/Devolutions.Terminal.App/AssemblyInfo.cs b/src/Devolutions.Terminal.App/AssemblyInfo.cs index ea4f714..b274eb2 100644 --- a/src/Devolutions.Terminal.App/AssemblyInfo.cs +++ b/src/Devolutions.Terminal.App/AssemblyInfo.cs @@ -1,3 +1,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Devolutions.Terminal.App.Tests")] +[assembly: InternalsVisibleTo("Devolutions.Terminal.UI.Tests")] diff --git a/src/Devolutions.Terminal.App/Views/MainWindow.Lifecycle.cs b/src/Devolutions.Terminal.App/Views/MainWindow.Lifecycle.cs new file mode 100644 index 0000000..f86fee2 --- /dev/null +++ b/src/Devolutions.Terminal.App/Views/MainWindow.Lifecycle.cs @@ -0,0 +1,113 @@ +using Avalonia.Controls; +using Devolutions.Terminal.App.Actions; + +namespace Devolutions.Terminal.App.Views; + +public partial class MainWindow +{ + private void DetachPaneControls(TerminalTab tab) + { + foreach (var pane in tab.Panes.Leaves()) + { + DetachControl(pane.Control); + if (_paneScrollBars.TryGetValue(pane, out var scrollBar)) + { + DetachControl(scrollBar); + } + } + } + + private static void DetachControl(Control control) + { + if (control.Parent is Decorator decorator) + { + decorator.Child = null; + } + else if (control.Parent is Panel panel) + { + panel.Children.Remove(control); + } + } + + private async Task ConfirmCloseAsync(IEnumerable panes, bool automaticExit = false) + { + var snapshot = panes.ToArray(); + var sessions = snapshot.Select(pane => pane.Control.ProcessMetadata).ToArray(); + var running = snapshot.Count(pane => pane.Control.IsRunning); + if (!CloseConfirmationPolicy.RequiresConfirmation(_settings.ConfirmOnClose, running, automaticExit)) + { + return true; + } + + return await _confirmationDialog.ShowAsync(this, "Close terminal sessions", + $"Close {running} running terminal session(s)? Unsaved work may be lost.", "Close sessions").ConfigureAwait(true) && + panes.SequenceEqual(snapshot) && + snapshot.Select(pane => pane.Control.ProcessMetadata).SequenceEqual(sessions); + } + + private async Task ConfirmWindowCloseAsync() + { + _closeConfirmationPending = true; + try + { + if (await ConfirmCloseAsync(_tabs.SelectMany(tab => tab.Panes.Leaves())).ConfigureAwait(true) && + !_isClosed) + { + _closeApproved = true; + Close(); + } + } + finally + { + _closeConfirmationPending = false; + _closeApproved = false; + } + } + + protected override void OnClosing(WindowClosingEventArgs e) + { + if (AboutOverlay.IsVisible) + { + e.Cancel = true; + CloseAbout(); + return; + } + + base.OnClosing(e); + if (!e.Cancel && !_closeApproved && + CloseConfirmationPolicy.RequiresConfirmation(_settings.ConfirmOnClose, + _tabs.SelectMany(tab => tab.Panes.Leaves()).Count(pane => pane.Control.IsRunning))) + { + e.Cancel = true; + if (!_closeConfirmationPending) + { + _ = ConfirmWindowCloseAsync(); + } + } + } + + protected override async void OnClosed(EventArgs e) + { + _isClosed = true; + if (!_layoutPersisted && _tabs.Count > 0) + { + TryPersistCurrentLayout(CaptureLayout()); + } + + foreach (var tab in _tabs.ToArray()) + { + foreach (var pane in tab.Panes.Leaves()) + { + await pane.Control.CloseAsync().ConfigureAwait(true); + } + } + + foreach (var bitmap in _tabIconCache.Values) + { + bitmap.Dispose(); + } + + _tabIconCache.Clear(); + base.OnClosed(e); + } +} diff --git a/src/Devolutions.Terminal.App/Views/MainWindow.axaml.cs b/src/Devolutions.Terminal.App/Views/MainWindow.axaml.cs index 42fcd57..1742ee8 100644 --- a/src/Devolutions.Terminal.App/Views/MainWindow.axaml.cs +++ b/src/Devolutions.Terminal.App/Views/MainWindow.axaml.cs @@ -39,6 +39,9 @@ public partial class MainWindow : private static string? _lastJumpListFingerprint; private static long _lastSystemToastTick; private AppSettings _settings; + private readonly TerminalConfirmationDialog _confirmationDialog = new(); + private bool _closeApproved; + private bool _closeConfirmationPending; private readonly ApplicationStateStore _stateStore; private readonly TerminalConnectionFactory _connectionFactory; private readonly DynamicProfileManager _dynamicProfileManager; @@ -526,6 +529,11 @@ private TerminalPane CreatePane( var control = new TermControl(TerminalEngineFactory.Create(_settings, profile)); control.ConnectionFactory = CreateConnection; control.InteractionOptions = TerminalInteractionOptions.FromSettings(_settings); + control.ConfirmPasteAsync = request => _confirmationDialog.ShowAsync( + this, "Confirm paste", + $"Paste {request.CharacterCount:N0} characters across {request.LineCount:N0} line(s)? " + + "Pasted text may execute commands in the terminal.", + "Paste"); control.Cursor = new Cursor(StandardCursorType.Ibeam); control.NotificationRequested += (_, notification) => ShowNotification(notification); control.InteractionError += (_, error) => ShowNotification(new TerminalNotification( @@ -571,7 +579,7 @@ private TerminalPane CreatePane( var tab = FindTab(pane); if (tab is not null) { - await ClosePaneAsync(tab, pane).ConfigureAwait(true); + await ClosePaneAsync(tab, pane, automaticExit: true).ConfigureAwait(true); } }; return pane; @@ -679,7 +687,7 @@ private void ActivatePane(TerminalTab tab, TerminalPane pane) pane.Control.Focus(); } - private async Task ClosePaneAsync(TerminalTab tab, TerminalPane pane) + private async Task ClosePaneAsync(TerminalTab tab, TerminalPane pane, bool automaticExit = false) { if (tab.IsClosing) { @@ -688,11 +696,12 @@ private async Task ClosePaneAsync(TerminalTab tab, TerminalPane pane) if (tab.Panes.Count == 1) { - await CloseTabAsync(tab).ConfigureAwait(true); + await CloseTabAsync(tab, automaticExit: automaticExit).ConfigureAwait(true); return; } - if (!tab.Panes.Close(pane)) + if (!await ConfirmCloseAsync([pane], automaticExit).ConfigureAwait(true) || + tab.IsClosing || !tab.Panes.Close(pane)) { return; } @@ -708,13 +717,20 @@ private async Task ClosePaneAsync(TerminalTab tab, TerminalPane pane) } } - private async Task CloseTabAsync(TerminalTab tab, bool remember = true) + private async Task CloseTabAsync( + TerminalTab tab, bool remember = true, bool automaticExit = false, bool confirmed = false) { if (tab.IsClosing) { return; } + if ((!confirmed && !await ConfirmCloseAsync(tab.Panes.Leaves(), automaticExit).ConfigureAwait(true)) || + tab.IsClosing) + { + return; + } + var finalLayout = _tabs.Count == 1 ? CaptureLayout() : null; tab.IsClosing = true; var wasActive = ReferenceEquals(_activeTab, tab); @@ -2083,9 +2099,15 @@ private void ActivateRelativeTab(int delta, bool mostRecentlyUsed = false) private async Task CloseOtherTabsAsync(uint? index) { var keep = ResolveTab(index) ?? _activeTab; - foreach (var tab in _tabs.Where(tab => !ReferenceEquals(tab, keep)).ToArray()) + var closing = _tabs.Where(tab => !ReferenceEquals(tab, keep)).ToArray(); + if (!await ConfirmCloseAsync(closing.SelectMany(tab => tab.Panes.Leaves())).ConfigureAwait(true)) { - await CloseTabAsync(tab).ConfigureAwait(true); + return; + } + + foreach (var tab in closing) + { + await CloseTabAsync(tab, confirmed: true).ConfigureAwait(true); } if (keep is not null) @@ -2098,15 +2120,29 @@ private async Task CloseTabsAfterAsync(uint? index) { var keep = ResolveTab(index) ?? _activeTab; var keepIndex = keep is null ? -1 : TabIndexOf(keep); - foreach (var tab in _tabs.Skip(keepIndex + 1).ToArray()) + var closing = _tabs.Skip(keepIndex + 1).ToArray(); + if (!await ConfirmCloseAsync(closing.SelectMany(tab => tab.Panes.Leaves())).ConfigureAwait(true)) { - await CloseTabAsync(tab).ConfigureAwait(true); + return; + } + + foreach (var tab in closing) + { + await CloseTabAsync(tab, confirmed: true).ConfigureAwait(true); } } private async Task CloseOtherPanesAsync() { - var closed = _activeTab!.Panes.CloseOthers(); + var tab = _activeTab!; + var keep = tab.Panes.ActiveContent; + if (!await ConfirmCloseAsync(tab.Panes.Leaves().Where(pane => !ReferenceEquals(pane, keep))).ConfigureAwait(true) || + tab.IsClosing || !ReferenceEquals(_activeTab, tab) || !ReferenceEquals(tab.Panes.ActiveContent, keep)) + { + return; + } + + var closed = tab.Panes.CloseOthers(); foreach (var pane in closed) { await pane.Control.CloseAsync().ConfigureAwait(true); @@ -4036,68 +4072,6 @@ private struct CursorPoint [return: MarshalAs(UnmanagedType.Bool)] private static partial bool GetCursorPosition(out CursorPoint point); - private void DetachPaneControls(TerminalTab tab) - { - foreach (var pane in tab.Panes.Leaves()) - { - DetachControl(pane.Control); - if (_paneScrollBars.TryGetValue(pane, out var scrollBar)) - { - DetachControl(scrollBar); - } - } - } - - private static void DetachControl(Control control) - { - if (control.Parent is Decorator decorator) - { - decorator.Child = null; - } - else if (control.Parent is Panel panel) - { - panel.Children.Remove(control); - } - } - - protected override void OnClosing(WindowClosingEventArgs e) - { - if (AboutOverlay.IsVisible) - { - e.Cancel = true; - CloseAbout(); - return; - } - - base.OnClosing(e); - } - - protected override async void OnClosed(EventArgs e) - { - _isClosed = true; - if (!_layoutPersisted && _tabs.Count > 0) - { - TryPersistCurrentLayout(CaptureLayout()); - } - - foreach (var tab in _tabs.ToArray()) - { - foreach (var pane in tab.Panes.Leaves()) - { - await pane.Control.CloseAsync().ConfigureAwait(true); - } - } - - foreach (var bitmap in _tabIconCache.Values) - { - bitmap.Dispose(); - } - - _tabIconCache.Clear(); - - base.OnClosed(e); - } - private sealed class PaneScrollBar : Grid { private readonly TerminalPane _pane; diff --git a/src/Devolutions.Terminal.App/Views/TerminalConfirmationDialog.cs b/src/Devolutions.Terminal.App/Views/TerminalConfirmationDialog.cs new file mode 100644 index 0000000..9c58eff --- /dev/null +++ b/src/Devolutions.Terminal.App/Views/TerminalConfirmationDialog.cs @@ -0,0 +1,58 @@ +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; + +namespace Devolutions.Terminal.App.Views; + +internal sealed class TerminalConfirmationDialog +{ + private bool _isOpen; + + public async Task ShowAsync(Window owner, string title, string message, string acceptLabel) + { + // A second action must not silently reuse consent for a different operation. + if (_isOpen) + { + return false; + } + + _isOpen = true; + try + { + var cancel = new Button { Content = "Cancel", IsCancel = true }; + var accept = new Button { Content = acceptLabel }; + var dialog = new Window + { + Title = title, + Width = 460, + SizeToContent = SizeToContent.Height, + CanResize = false, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + Content = new StackPanel + { + Margin = new Avalonia.Thickness(24), + Spacing = 20, + Children = + { + new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap }, + new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Spacing = 12, + Children = { cancel, accept }, + }, + }, + }, + }; + cancel.Click += (_, _) => dialog.Close(false); + accept.Click += (_, _) => dialog.Close(true); + dialog.Opened += (_, _) => cancel.Focus(); + return await dialog.ShowDialog(owner).ConfigureAwait(true); + } + finally + { + _isOpen = false; + } + } +} diff --git a/src/Devolutions.Terminal.Broker/AssemblyInfo.cs b/src/Devolutions.Terminal.Broker/AssemblyInfo.cs new file mode 100644 index 0000000..b4f3ac8 --- /dev/null +++ b/src/Devolutions.Terminal.Broker/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Devolutions.Terminal.Broker.Tests")] diff --git a/src/Devolutions.Terminal.Broker/BrokerHost.cs b/src/Devolutions.Terminal.Broker/BrokerHost.cs index 291c516..096b5f6 100644 --- a/src/Devolutions.Terminal.Broker/BrokerHost.cs +++ b/src/Devolutions.Terminal.Broker/BrokerHost.cs @@ -9,16 +9,13 @@ namespace Devolutions.Terminal.Broker; public sealed class BrokerHost : IAsyncDisposable { private const int AcceptLoopCount = 8; - private const int MaximumCachedResponses = 1024; - private static readonly TimeSpan ResponseRetryWindow = TimeSpan.FromSeconds(5); private readonly BrokerEndpointStore _endpointStore; private readonly IBrokerRequestHandler _handler; private readonly BrokerElection _election; private readonly CancellationTokenSource _shutdown = new(); private readonly ConcurrentDictionary _connections = []; - private readonly ConcurrentDictionary _responses = []; - private readonly ConcurrentQueue _responseOrder = []; + private readonly BrokerResponseCache _responses = new(); private readonly Task[] _acceptLoops; private int _connectionId; @@ -184,35 +181,26 @@ private async ValueTask DispatchOnceAsync( { if (string.IsNullOrWhiteSpace(request.RequestId)) { - return await DispatchAsync(request, cancellationToken).ConfigureAwait(false); + return new(BrokerProtocol.Version, request.RequestId, BrokerStatus.InvalidRequest, + "A request ID is required for bounded, retry-safe dispatch."); } - var candidate = new CachedResponse( - new Lazy>( - () => DispatchAsync(request, _shutdown.Token).AsTask(), - LazyThreadSafetyMode.ExecutionAndPublication), - DateTimeOffset.UtcNow); - var operation = _responses.GetOrAdd(request.RequestId, candidate); - if (ReferenceEquals(operation, candidate)) + if (request.ProtocolVersion != BrokerProtocol.Version || + !string.Equals(request.UserIdentity, BrokerIdentity.CurrentUser, StringComparison.Ordinal) || + !FixedTimeEquals(request.AuthenticationToken, Endpoint.AuthenticationToken)) { - _responseOrder.Enqueue(request.RequestId); - while (_responses.Count > MaximumCachedResponses && - _responseOrder.TryDequeue(out var expired)) - { - if (_responses.TryGetValue(expired, out var cached) && - (!cached.Operation.IsValueCreated || - !cached.Operation.Value.IsCompleted || - DateTimeOffset.UtcNow - cached.CreatedAt < ResponseRetryWindow)) - { - _responseOrder.Enqueue(expired); - break; - } + return await DispatchAsync(request, cancellationToken).ConfigureAwait(false); + } - _responses.TryRemove(expired, out _); - } + var operation = _responses.GetOrAdd(request.RequestId, + () => DispatchAsync(request, _shutdown.Token).AsTask()); + if (operation is null) + { + return new(BrokerProtocol.Version, request.RequestId, BrokerStatus.Unavailable, + "Broker request capacity is full. No action was executed; retry after five seconds."); } - return await operation.Operation.Value.WaitAsync(cancellationToken).ConfigureAwait(false); + return await operation.WaitAsync(cancellationToken).ConfigureAwait(false); } private async ValueTask DispatchAsync( @@ -317,10 +305,6 @@ internal static async ValueTask WriteFrameAsync( await stream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false); await stream.FlushAsync(cancellationToken).ConfigureAwait(false); } - - private sealed record CachedResponse( - Lazy> Operation, - DateTimeOffset CreatedAt); } internal static class BrokerIdentity diff --git a/src/Devolutions.Terminal.Broker/BrokerResponseCache.cs b/src/Devolutions.Terminal.Broker/BrokerResponseCache.cs new file mode 100644 index 0000000..74953b0 --- /dev/null +++ b/src/Devolutions.Terminal.Broker/BrokerResponseCache.cs @@ -0,0 +1,67 @@ +namespace Devolutions.Terminal.Broker; + +internal sealed class BrokerResponseCache( + int capacity = 1024, + int activeCapacity = 128, + TimeProvider? timeProvider = null) +{ + private static readonly TimeSpan RetryWindow = TimeSpan.FromSeconds(5); + private readonly object _gate = new(); + private readonly Dictionary _entries = new(StringComparer.Ordinal); + private readonly TimeProvider _clock = timeProvider ?? TimeProvider.System; + private int _active; + + public Task? GetOrAdd(string id, Func> dispatch) + { + Entry entry; + lock (_gate) + { + // Completed retention begins at completion, not admission. Active requests + // never expire: a retry must join the original operation even under load. + foreach (var expired in _entries.Where(pair => + pair.Value.CompletedAt is { } completed && + _clock.GetElapsedTime(completed) >= RetryWindow).Select(pair => pair.Key).ToArray()) + { + _entries.Remove(expired); + } + + if (!_entries.TryGetValue(id, out entry!)) + { + if (_entries.Count >= capacity || _active >= activeCapacity) + { + return null; + } + + entry = new Entry(); + entry.Operation = new Lazy>( + () => RunAsync(entry, dispatch), LazyThreadSafetyMode.ExecutionAndPublication); + _entries.Add(id, entry); + _active++; + } + } + + return entry.Operation.Value; + } + + private async Task RunAsync(Entry entry, Func> dispatch) + { + try + { + return await dispatch().ConfigureAwait(false); + } + finally + { + lock (_gate) + { + entry.CompletedAt = _clock.GetTimestamp(); + _active--; + } + } + } + + private sealed class Entry + { + public Lazy> Operation { get; set; } = null!; + public long? CompletedAt { get; set; } + } +} diff --git a/src/Devolutions.Terminal.Connection/AssemblyInfo.cs b/src/Devolutions.Terminal.Connection/AssemblyInfo.cs new file mode 100644 index 0000000..1c7d855 --- /dev/null +++ b/src/Devolutions.Terminal.Connection/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Devolutions.Terminal.Connection.Tests")] diff --git a/src/Devolutions.Terminal.Connection/ConPtyConnection.cs b/src/Devolutions.Terminal.Connection/ConPtyConnection.cs index 2495a54..52b2c49 100644 --- a/src/Devolutions.Terminal.Connection/ConPtyConnection.cs +++ b/src/Devolutions.Terminal.Connection/ConPtyConnection.cs @@ -12,7 +12,7 @@ public sealed class ConPtyConnection : IRestartableTerminalConnection { private readonly object _stateLock = new(); private readonly SemaphoreSlim _lifecycleLock = new(1, 1); - private readonly SemaphoreSlim _writeLock = new(1, 1); + private OrderedInputWriter? _writer; private SessionResources? _session; private TerminalLaunchOptions? _lastOptions; private long _generation; @@ -122,17 +122,7 @@ public void Write(ReadOnlySpan data) return; } - _writeLock.Wait(); - try - { - var stream = GetWritableStream(); - stream.Write(data); - stream.Flush(); - } - finally - { - _writeLock.Release(); - } + GetWriter().Post(data); } public void Write(string text) @@ -153,26 +143,7 @@ public async ValueTask WriteAsync( return; } - CancellationToken lifetimeToken; - lock (_stateLock) - { - lifetimeToken = _session?.Lifetime.Token ?? CancellationToken.None; - } - - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( - cancellationToken, - lifetimeToken); - await _writeLock.WaitAsync(linkedCts.Token).ConfigureAwait(false); - try - { - var stream = GetWritableStream(); - await stream.WriteAsync(data, linkedCts.Token).ConfigureAwait(false); - await stream.FlushAsync(linkedCts.Token).ConfigureAwait(false); - } - finally - { - _writeLock.Release(); - } + await GetWriter().Enqueue(data.Span, cancellationToken).ConfigureAwait(false); } public void Resize(int columns, int rows) @@ -290,6 +261,25 @@ private void StartCore(TerminalLaunchOptions options, CancellationToken cancella { ObjectDisposedException.ThrowIf(_disposed, this); _session = session; + _writer = new OrderedInputWriter( + async (data, token) => + { + await session.Input.WriteAsync(data, token).ConfigureAwait(false); + await session.Input.FlushAsync(token).ConfigureAwait(false); + }, + error => + { + if (session.Lifetime.IsCancellationRequested || session.ExitPublished) + { + Faulted?.Invoke(this, error); + } + else + { + PublishFault(session, error); + } + }, + session.Lifetime.Token, + () => Cancel(session.Generation)); _lastOptions = options; _hasStarted = true; ProcessMetadata = metadata; @@ -423,7 +413,7 @@ private void Cancel(long generation) } } - private FileStream GetWritableStream() + private OrderedInputWriter GetWriter() { lock (_stateLock) { @@ -433,7 +423,7 @@ private FileStream GetWritableStream() throw new InvalidOperationException("The ConPTY connection is not running."); } - return _session.Input; + return _writer!; } } @@ -625,17 +615,14 @@ private async Task> CleanupSessionResourcesAsync( bool drainOutput) { session.CancellationRegistration.Dispose(); - await _writeLock.WaitAsync().ConfigureAwait(false); - try - { - session.Input.Dispose(); - } - finally - { - _writeLock.Release(); - } + _writer?.Complete(); + session.Input.Dispose(); var taskErrors = new List(); + if (_writer is { } writer) + { + await ObserveAsync(writer.Completion, taskErrors).ConfigureAwait(false); + } if (drainOutput) { if (session.ReadTask is not null) diff --git a/src/Devolutions.Terminal.Connection/LinuxPtyConnection.cs b/src/Devolutions.Terminal.Connection/LinuxPtyConnection.cs index e158331..8801014 100644 --- a/src/Devolutions.Terminal.Connection/LinuxPtyConnection.cs +++ b/src/Devolutions.Terminal.Connection/LinuxPtyConnection.cs @@ -10,7 +10,7 @@ public sealed class LinuxPtyConnection : IRestartableTerminalConnection { private readonly object _stateLock = new(); private readonly SemaphoreSlim _lifecycleLock = new(1, 1); - private readonly SemaphoreSlim _writeLock = new(1, 1); + private OrderedInputWriter? _writer; private Process? _process; private Stream? _input; private CancellationTokenSource? _lifetime; @@ -123,15 +123,7 @@ public void Write(ReadOnlySpan data) return; } - _writeLock.Wait(); - try - { - WriteFrame(GetInput(), data); - } - finally - { - _writeLock.Release(); - } + GetWriter().Post(CreateDataFrame(data)); } public void Write(string text) @@ -149,36 +141,16 @@ public async ValueTask WriteAsync( return; } - await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - var input = GetInput(); - await WriteFrameAsync(input, data, cancellationToken).ConfigureAwait(false); - } - finally - { - _writeLock.Release(); - } + await GetWriter().Enqueue(CreateDataFrame(data.Span), cancellationToken).ConfigureAwait(false); } public void Resize(int columns, int rows) { columns = Math.Clamp(columns, 1, ushort.MaxValue); rows = Math.Clamp(rows, 1, ushort.MaxValue); - _writeLock.Wait(); - try - { - var header = Encoding.ASCII.GetBytes($"R {columns} {rows}\n"); - var input = GetInput(); - input.Write(header); - input.Flush(); - Columns = columns; - Rows = rows; - } - finally - { - _writeLock.Release(); - } + GetWriter().Post(Encoding.ASCII.GetBytes($"R {columns} {rows}\n")); + Columns = columns; + Rows = rows; } public async ValueTask DisposeAsync() @@ -239,6 +211,28 @@ private void StartCore(TerminalLaunchOptions options, CancellationToken cancella _process = process; _input = process.StandardInput.BaseStream; _lifetime = lifetime; + var input = _input; + _writer = new OrderedInputWriter( + async (data, token) => + { + await input.WriteAsync(data, token).ConfigureAwait(false); + await input.FlushAsync(token).ConfigureAwait(false); + }, + error => + { + Faulted?.Invoke(this, error); + lock (_stateLock) + { + if (generation == _generation && !lifetime.IsCancellationRequested) + { + _requestedExitReason ??= TerminalExitReason.ConnectionFailure; + lifetime.Cancel(); + KillIfRunning(process); + } + } + }, + lifetime.Token, + () => Cancel(generation)); _lastOptions = options; _hasStarted = true; _exitPublished = false; @@ -443,31 +437,46 @@ private async Task StopCoreAsync( } _cancellationRegistration.Dispose(); - await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(TimeSpan.FromSeconds(1)); try { - if (_input is not null) + if (_writer is { } writer) { - var close = Encoding.ASCII.GetBytes("C\n"); - await _input.WriteAsync(close, cancellationToken).ConfigureAwait(false); - await _input.FlushAsync(cancellationToken).ConfigureAwait(false); + writer.Post("C\n"u8); + writer.Complete(); + await writer.Completion.WaitAsync(deadline.Token).ConfigureAwait(false); } + + await process.WaitForExitAsync(deadline.Token).ConfigureAwait(false); } - catch (IOException) + catch (Exception ex) when (ex is IOException or OperationCanceledException) { + if (!process.HasExited) + { + Faulted?.Invoke(this, new IOException( + "Terminal shutdown could not drain input; the PTY host will be terminated.", ex)); + KillIfRunning(process); + } } finally { - _writeLock.Release(); + _writer?.Complete(); + if (_lifetime is not null) + { + await _lifetime.CancelAsync().ConfigureAwait(false); + } } - var completed = await Task.WhenAny( - process.WaitForExitAsync(cancellationToken), - Task.Delay(TimeSpan.FromSeconds(1), cancellationToken)).ConfigureAwait(false); - if (!process.HasExited && completed.IsCompleted) + if (!process.HasExited) { KillIfRunning(process); - await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + } + + await process.WaitForExitAsync().ConfigureAwait(false); + if (_writer is not null) + { + await _writer.Completion.ConfigureAwait(false); } if (_readTask is not null) @@ -502,6 +511,7 @@ private void CleanupProcess(Process process) { _process = null; _input = null; + _writer = null; _lifetime = null; } @@ -533,12 +543,17 @@ private void RecordStartupFailure(Exception? error) } } - private Stream GetInput() + private OrderedInputWriter GetWriter() { lock (_stateLock) { ObjectDisposedException.ThrowIf(_disposed, this); - return _input ?? throw new InvalidOperationException( + if (State != TerminalConnectionState.Connected) + { + throw new InvalidOperationException("The Linux PTY connection is not accepting input."); + } + + return _writer ?? throw new InvalidOperationException( "The Linux PTY connection is not running."); } @@ -573,23 +588,18 @@ private static void KillIfRunning(Process process) } } - private static void WriteFrame(Stream input, ReadOnlySpan data) + private static byte[] CreateDataFrame(ReadOnlySpan data) { - var header = Encoding.ASCII.GetBytes($"D {data.Length}\n"); - input.Write(header); - input.Write(data); - input.Flush(); - } + if (data.Length > OrderedInputWriter.MaximumBytes - 32) + { + throw new IOException("Terminal input exceeds the 4 MiB queue limit."); + } - private static async Task WriteFrameAsync( - Stream input, - ReadOnlyMemory data, - CancellationToken cancellationToken) - { var header = Encoding.ASCII.GetBytes($"D {data.Length}\n"); - await input.WriteAsync(header, cancellationToken).ConfigureAwait(false); - await input.WriteAsync(data, cancellationToken).ConfigureAwait(false); - await input.FlushAsync(cancellationToken).ConfigureAwait(false); + var frame = new byte[header.Length + data.Length]; + header.CopyTo(frame, 0); + data.CopyTo(frame.AsSpan(header.Length)); + return frame; } private static void ValidateOptions( diff --git a/src/Devolutions.Terminal.Connection/OrderedInputWriter.cs b/src/Devolutions.Terminal.Connection/OrderedInputWriter.cs new file mode 100644 index 0000000..7d86509 --- /dev/null +++ b/src/Devolutions.Terminal.Connection/OrderedInputWriter.cs @@ -0,0 +1,167 @@ +using System.Threading.Channels; + +namespace Devolutions.Terminal.Connection; + +internal sealed class OrderedInputWriter +{ + internal const int MaximumBytes = 4 * 1024 * 1024; + private readonly object _gate = new(); + private readonly Channel _queue = Channel.CreateBounded(256); + private readonly Func, CancellationToken, ValueTask> _write; + private readonly Action _fault; + private readonly CancellationToken _lifetime; + private readonly Action? _interrupt; + private int _bufferedBytes; + private bool _stopped; + + public OrderedInputWriter( + Func, CancellationToken, ValueTask> write, + Action fault, + CancellationToken lifetime, + Action? interrupt = null) + { + _write = write; + _fault = fault; + _lifetime = lifetime; + _interrupt = interrupt; + Completion = Task.Run(RunAsync); + } + + public Task Completion { get; } + + public void Post(ReadOnlySpan data) + { + var completion = Enqueue(data); + _ = completion.ContinueWith(task => _ = task.Exception, + CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + public Task Enqueue(ReadOnlySpan data, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + lock (_gate) + { + if (_stopped || _lifetime.IsCancellationRequested) + { + throw new IOException("Terminal input is closed."); + } + + if (data.Length > MaximumBytes - _bufferedBytes) + { + throw new IOException("Terminal input queue is full (4 MiB). Wait for the terminal to read input and retry."); + } + + var pending = new PendingWrite(data.ToArray(), cancellationToken, _interrupt); + if (!_queue.Writer.TryWrite(pending)) + { + pending.Dispose(); + throw new IOException("Terminal input queue is full (256 writes). Wait for the terminal to read input and retry."); + } + + _bufferedBytes += data.Length; + return pending.Completion.Task; + } + } + + public void Complete() + { + lock (_gate) + { + _stopped = true; + _queue.Writer.TryComplete(); + } + } + + private async Task RunAsync() + { + Exception? failure = null; + var interruptedBytes = 0; + try + { + await foreach (var pending in _queue.Reader.ReadAllAsync(_lifetime).ConfigureAwait(false)) + { + try + { + // An in-flight frame cannot be skipped safely. Cancellation stops + // the session rather than allowing another frame after partial input. + if (!pending.TryStart()) + { + continue; + } + + using var linked = CancellationTokenSource.CreateLinkedTokenSource(_lifetime, pending.CancellationToken); + await _write(pending.Data, linked.Token).ConfigureAwait(false); + pending.MarkCompleted(); + pending.Completion.TrySetResult(); + } + catch (Exception ex) when (ex is IOException or ObjectDisposedException or OperationCanceledException or InvalidOperationException) + { + failure = ex; + interruptedBytes = pending.Data.Length; + pending.Completion.TrySetException(ex); + break; + } + finally + { + pending.Dispose(); + lock (_gate) + { + _bufferedBytes -= pending.Data.Length; + } + } + } + } + catch (OperationCanceledException ex) + { + failure = ex; + } + finally + { + Complete(); + var discarded = 0; + while (_queue.Reader.TryRead(out var pending)) + { + discarded += pending.Completion.Task.IsCanceled ? 0 : pending.Data.Length; + pending.Completion.TrySetException(failure ?? new IOException("Terminal input is closed.")); + pending.Dispose(); + } + + if (failure is not null && (failure is not OperationCanceledException || discarded + interruptedBytes > 0)) + { + _fault(new IOException("Terminal input did not complete; queued input was not delivered.", failure)); + } + } + } + + private sealed class PendingWrite : IDisposable + { + private readonly CancellationTokenRegistration _registration; + private int _state; + + public PendingWrite(byte[] data, CancellationToken cancellationToken, Action? interrupt) + { + Data = data; + CancellationToken = cancellationToken; + _registration = cancellationToken.Register(() => + { + var state = Interlocked.CompareExchange(ref _state, 2, 0); + if (state == 0) + { + Completion.TrySetCanceled(cancellationToken); + } + else if (state == 1) + { + interrupt?.Invoke(); + } + }); + } + + public byte[] Data { get; } + public CancellationToken CancellationToken { get; } + public TaskCompletionSource Completion { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public bool TryStart() => Interlocked.CompareExchange(ref _state, 1, 0) == 0; + public void MarkCompleted() => Interlocked.Exchange(ref _state, 3); + public void Dispose() => _registration.Dispose(); + } +} diff --git a/src/Devolutions.Terminal.Control/TermControl.cs b/src/Devolutions.Terminal.Control/TermControl.cs index dfee7bd..67e3b0a 100644 --- a/src/Devolutions.Terminal.Control/TermControl.cs +++ b/src/Devolutions.Terminal.Control/TermControl.cs @@ -171,7 +171,7 @@ public TermControl(ITerminalEngine? engine = null) Dispatcher.UIThread.Post(() => TitleChanged?.Invoke(this, title)); } }; - Engine.ResponseReady += (_, data) => _connection?.Write(data); + Engine.ResponseReady += (_, data) => TryWriteInput(data); Engine.ClipboardWriteRequested += (_, text) => Dispatcher.UIThread.Post(() => SetClipboardFromTerminalObservedAsync(text)); Engine.NotificationRequested += (_, notification) => @@ -439,6 +439,34 @@ public async Task PasteAsync(TerminalPasteOptions options) ArgumentNullException.ThrowIfNull(options); var clipboard = TopLevel.GetTopLevel(this)?.Clipboard; var text = clipboard is null ? null : await clipboard.TryGetTextAsync().ConfigureAwait(true); + return await PasteTextAsync(text, options).ConfigureAwait(true); + } + + public Func>? ConfirmPasteAsync { get; set; } + + public async Task PasteTextAsync( + string? text, TerminalPasteOptions? options = null) + { + var request = TerminalInteractionModel.PreparePaste( + text, options ?? InteractionOptions.Paste, Engine.BracketedPaste); + var connection = _connection; + var session = ProcessMetadata; + if (request.RequiresConfirmation && ConfirmPasteAsync is { } confirm) + { + if (!await confirm(request).ConfigureAwait(true)) + { + return TerminalPasteResult.Cancelled; + } + + if (!ReferenceEquals(connection, _connection) || connection?.IsRunning != true || + !Equals(session, ProcessMetadata) || Engine.BracketedPaste != request.BracketedPaste) + { + return TerminalPasteResult.NoConnection; + } + + return WritePaste(request); + } + return PasteText(text, options); } @@ -456,22 +484,27 @@ public TerminalPasteResult PasteText(string? text, TerminalPasteOptions? options if (request.RequiresConfirmation) { var args = new TerminalPasteWarningEventArgs(request); - if (PasteWarning is not null) + PasteWarning?.Invoke(this, args); + if (!args.Allow) { - PasteWarning.Invoke(this, args); - if (!args.Allow) - { - return TerminalPasteResult.Cancelled; - } + return TerminalPasteResult.Cancelled; } } + return WritePaste(request); + } + + private TerminalPasteResult WritePaste(TerminalPasteRequest request) + { if (_connection is null) { return TerminalPasteResult.NoConnection; } - _connection.Write(Engine.WrapPaste(request.Text)); + if (!TryWriteInput(Engine.WrapPaste(request.Text))) + { + return TerminalPasteResult.InputRejected; + } SetScrollOffset(0); return TerminalPasteResult.Written; } @@ -486,10 +519,31 @@ public void ClearBuffer() public void WriteInput(string input) { ArgumentNullException.ThrowIfNull(input); - _connection?.Write(input); + TryWriteInput(input); SetScrollOffset(0); } + private bool TryWriteInput(string input) => TryWriteInput(Encoding.UTF8.GetBytes(input)); + + private bool TryWriteInput(ReadOnlySpan input) + { + if (_connection is null) + { + return false; + } + + try + { + _connection.Write(input); + return true; + } + catch (Exception ex) when (ex is IOException or InvalidOperationException or ObjectDisposedException) + { + ReportInteractionError("Terminal input was not accepted", ex); + return false; + } + } + public void SelectAll() { var snapshot = Engine.CreateSnapshot(includeHistory: true).Buffer; @@ -891,7 +945,17 @@ protected override Size ArrangeOverride(Size finalSize) ResizeEngine(cols, rows); if (gridChanged) { - _connection?.Resize(cols, rows); + try + { + if (_connection?.IsRunning == true) + { + _connection.Resize(cols, rows); + } + } + catch (Exception ex) when (ex is IOException or InvalidOperationException) + { + ReportInteractionError("Terminal resize was not accepted; resize again after input drains", ex); + } } return finalSize; @@ -1013,7 +1077,7 @@ protected override void OnKeyUp(KeyEventArgs e) TerminalKeyEventType.Release); if (vt is not null) { - _connection?.Write(vt); + TryWriteInput(vt); e.Handled = true; } } @@ -1054,7 +1118,7 @@ protected override void OnTextInput(TextInputEventArgs e) return null; } - _connection?.Write(vt); + TryWriteInput(vt); SetScrollOffset(0); if (IsTextInputCandidate(keySymbol) && (mode.Win32InputMode || @@ -1083,7 +1147,7 @@ protected override void OnTextInput(TextInputEventArgs e) } var output = KeyMapper.EncodeKittyTextInput(text, mode.KittyFlags) ?? text; - _connection?.Write(output); + TryWriteInput(output); SetScrollOffset(0); return output; } @@ -1163,7 +1227,7 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) x, y, Engine.ApplicationCursorKeys); - _connection?.Write(sequence); + TryWriteInput(sequence); e.Handled = true; base.OnPointerPressed(e); return; @@ -1722,7 +1786,7 @@ internal void SetImeSelectionOffset(int offset) return; } - _connection?.Write(TerminalInteractionModel.BuildCursorRepositionSequence( + TryWriteInput(TerminalInteractionModel.BuildCursorRepositionSequence( Engine.CursorX, Engine.CursorY, Engine.CursorX + delta, @@ -1978,7 +2042,7 @@ private void SendFocusChanged(bool focused) { if (Engine.FocusTracking) { - _connection?.Write(focused ? "\u001b[I" : "\u001b[O"); + TryWriteInput(focused ? "\u001b[I" : "\u001b[O"); } } @@ -1989,7 +2053,7 @@ private void WriteMouseInput( bool released, KeyModifiers modifiers) { - _connection?.Write(TerminalInteractionModel.BuildMouseSequence( + TryWriteInput(TerminalInteractionModel.BuildMouseSequence( button, x, y, diff --git a/src/Devolutions.Terminal.Control/TerminalInteractionContracts.cs b/src/Devolutions.Terminal.Control/TerminalInteractionContracts.cs index a465e05..43a073f 100644 --- a/src/Devolutions.Terminal.Control/TerminalInteractionContracts.cs +++ b/src/Devolutions.Terminal.Control/TerminalInteractionContracts.cs @@ -46,6 +46,7 @@ public enum TerminalPasteResult ConfirmationRequired, Cancelled, NoConnection, + InputRejected, } public enum TerminalScrollMarkKind diff --git a/src/Devolutions.Terminal.Settings.Editor/SettingsView.axaml b/src/Devolutions.Terminal.Settings.Editor/SettingsView.axaml index b1e80fb..bce3687 100644 --- a/src/Devolutions.Terminal.Settings.Editor/SettingsView.axaml +++ b/src/Devolutions.Terminal.Settings.Editor/SettingsView.axaml @@ -193,9 +193,9 @@ + Description="Not supported yet: automatic detection of plain-text URLs. Explicit terminal hyperlinks (OSC 8) remain available. Saved for compatibility only."> - + + Description="Not supported yet: window and pane animation effects are not configurable. Saved for compatibility only."> - + @@ -692,17 +692,21 @@ - + - + AvaloniaXamlLoader.Load(this); + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); +#if DEBUG + this.AttachDeveloperTools(); +#endif + } public override void OnFrameworkInitializationCompleted() { diff --git a/tests/Devolutions.Terminal.App.Tests/CloseConfirmationPolicyTests.cs b/tests/Devolutions.Terminal.App.Tests/CloseConfirmationPolicyTests.cs new file mode 100644 index 0000000..14cbfff --- /dev/null +++ b/tests/Devolutions.Terminal.App.Tests/CloseConfirmationPolicyTests.cs @@ -0,0 +1,25 @@ +using Devolutions.Terminal.App.Actions; +using Devolutions.Terminal.Settings; +using Xunit; + +namespace Devolutions.Terminal.App.Tests; + +public sealed class CloseConfirmationPolicyTests +{ + [Theory] + [InlineData(ConfirmOnClose.Never, 5, false)] + [InlineData(ConfirmOnClose.Automatic, 0, false)] + [InlineData(ConfirmOnClose.Automatic, 1, false)] + [InlineData(ConfirmOnClose.Automatic, 2, true)] + [InlineData(ConfirmOnClose.Always, 0, false)] + [InlineData(ConfirmOnClose.Always, 1, true)] + public void PolicyCountsOnlyRunningSessions(ConfirmOnClose policy, int running, bool expected) => + Assert.Equal(expected, CloseConfirmationPolicy.RequiresConfirmation(policy, running)); + + [Theory] + [InlineData(ConfirmOnClose.Never)] + [InlineData(ConfirmOnClose.Automatic)] + [InlineData(ConfirmOnClose.Always)] + public void AutomaticProcessExitNeverPrompts(ConfirmOnClose policy) => + Assert.False(CloseConfirmationPolicy.RequiresConfirmation(policy, 3, automaticExit: true)); +} diff --git a/tests/Devolutions.Terminal.Broker.Tests/BrokerResponseCacheTests.cs b/tests/Devolutions.Terminal.Broker.Tests/BrokerResponseCacheTests.cs new file mode 100644 index 0000000..1ae8dfd --- /dev/null +++ b/tests/Devolutions.Terminal.Broker.Tests/BrokerResponseCacheTests.cs @@ -0,0 +1,63 @@ +using Devolutions.Terminal.Broker; +using Xunit; + +namespace Devolutions.Terminal.Broker.Tests; + +public sealed class BrokerResponseCacheTests +{ + private static BrokerResponse Response(string id) => new(BrokerProtocol.Version, id, BrokerStatus.Success, id); + + [Fact] + public async Task PendingRequestCannotBlockExpiredCompletedEviction() + { + var clock = new TestClock(); + var cache = new BrokerResponseCache(2, 2, clock); + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var first = cache.GetOrAdd("pending", () => pending.Task); + await cache.GetOrAdd("completed", () => Task.FromResult(Response("completed")))!; + Assert.Null(cache.GetOrAdd("full", () => Task.FromResult(Response("full")))); + clock.Advance(TimeSpan.FromSeconds(6)); + Assert.Same(first, cache.GetOrAdd("pending", () => throw new InvalidOperationException())); + Assert.NotNull(cache.GetOrAdd("new", () => Task.FromResult(Response("new")))); + pending.SetResult(Response("pending")); + await first!; + } + + [Fact] + public async Task RetryRetentionBeginsWhenSlowRequestCompletes() + { + var clock = new TestClock(); + var cache = new BrokerResponseCache(1, 1, clock); + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var original = cache.GetOrAdd("slow", () => pending.Task); + clock.Advance(TimeSpan.FromMinutes(1)); + pending.SetResult(Response("slow")); + await original!; + clock.Advance(TimeSpan.FromSeconds(4)); + Assert.Null(cache.GetOrAdd("other", () => Task.FromResult(Response("other")))); + Assert.Same(original, cache.GetOrAdd("slow", () => throw new InvalidOperationException())); + clock.Advance(TimeSpan.FromSeconds(2)); + Assert.NotNull(cache.GetOrAdd("other", () => Task.FromResult(Response("other")))); + } + + [Fact] + public async Task ActiveAdmissionIsBoundedAndDuplicateJoinsOriginal() + { + var cache = new BrokerResponseCache(8, 1); + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var original = cache.GetOrAdd("one", () => pending.Task); + Assert.Null(cache.GetOrAdd("two", () => throw new InvalidOperationException())); + Assert.Same(original, cache.GetOrAdd("one", () => throw new InvalidOperationException())); + pending.SetResult(Response("one")); + await original!; + Assert.NotNull(cache.GetOrAdd("two", () => Task.FromResult(Response("two")))); + } + + private sealed class TestClock : TimeProvider + { + private long _timestamp; + public override long TimestampFrequency => TimeSpan.TicksPerSecond; + public override long GetTimestamp() => _timestamp; + public void Advance(TimeSpan duration) => _timestamp += duration.Ticks; + } +} diff --git a/tests/Devolutions.Terminal.Connection.Tests/ConnectionContractTests.cs b/tests/Devolutions.Terminal.Connection.Tests/ConnectionContractTests.cs index a43e733..2ef3bd1 100644 --- a/tests/Devolutions.Terminal.Connection.Tests/ConnectionContractTests.cs +++ b/tests/Devolutions.Terminal.Connection.Tests/ConnectionContractTests.cs @@ -12,6 +12,23 @@ public sealed class ConnectionContractTests { public static bool IsWindows => OperatingSystem.IsWindows(); + [Fact(Skip = "ConPTY is Windows-only.", SkipUnless = nameof(IsWindows))] + public async Task BlockedInputSubmissionAndCloseRemainResponsive() + { + await using var connection = new ConPtyConnection(); + var output = new List(); + connection.OutputReceived += (_, data) => { lock (output) output.AddRange(data.ToArray()); }; + await connection.StartAsync( + "powershell.exe -NoProfile -Command \"[Console]::Write('READY'); Start-Sleep -Seconds 30\"", + Environment.CurrentDirectory, 80, 24, TestContext.Current.CancellationToken); + await WaitForOutputAsync(output, "READY"); + var started = Stopwatch.StartNew(); + connection.Write(new string('x', 2 * 1024 * 1024)); + Assert.True(started.Elapsed < TimeSpan.FromSeconds(1), "Input submission blocked."); + await connection.CloseAsync().WaitAsync(TimeSpan.FromSeconds(5)); + Assert.False(connection.IsRunning); + } + [Fact(Skip = "ConPTY is Windows-only.", SkipUnless = nameof(IsWindows))] public async Task ConPtyStartsStopped() { diff --git a/tests/Devolutions.Terminal.Connection.Tests/LinuxPtyConnectionTests.cs b/tests/Devolutions.Terminal.Connection.Tests/LinuxPtyConnectionTests.cs index b582d18..f8828cc 100644 --- a/tests/Devolutions.Terminal.Connection.Tests/LinuxPtyConnectionTests.cs +++ b/tests/Devolutions.Terminal.Connection.Tests/LinuxPtyConnectionTests.cs @@ -11,6 +11,98 @@ public sealed class LinuxPtyConnectionTests { public static bool IsUnix => OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(); + [Fact(Skip = "Unix PTY host is Linux/macOS-only.", SkipUnless = nameof(IsUnix))] + public async Task BlockedInputStillRelaysOutputAndCloseHasDeadline() + { + await using var connection = new LinuxPtyConnection(); + var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var responsive = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var output = new StringBuilder(); + var faults = new List(); + connection.OutputReceived += (_, data) => + { + lock (output) + { + output.Append(Encoding.UTF8.GetString(data.Span)); + var text = output.ToString(); + if (text.Contains("READY", StringComparison.Ordinal)) ready.TrySetResult(); + if (text.Contains("RESPONSIVE", StringComparison.Ordinal)) responsive.TrySetResult(); + } + }; + connection.Faulted += (_, error) => { lock (faults) faults.Add(error); }; + await connection.StartAsync( + "stty -echo -icanon; printf READY; sleep 1; printf RESPONSIVE; sleep 30", + Environment.CurrentDirectory, 80, 24, TestContext.Current.CancellationToken); + await ready.Task.WaitAsync(TimeSpan.FromSeconds(5)); + var started = System.Diagnostics.Stopwatch.StartNew(); + connection.Write(new byte[2 * 1024 * 1024]); + Assert.True(started.Elapsed < TimeSpan.FromSeconds(1), "Input submission blocked."); + await responsive.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await connection.CloseAsync().WaitAsync(TimeSpan.FromSeconds(5)); + Assert.False(connection.IsRunning); + lock (faults) + { + Assert.NotEmpty(faults); + } + } + + [Fact(Skip = "Unix PTY host is Linux/macOS-only.", SkipUnless = nameof(IsUnix))] + public async Task CancellingBlockedFrameStopsSessionWithoutWaitingForReader() + { + await using var connection = new LinuxPtyConnection(); + using var cancellation = new CancellationTokenSource(); + var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var output = new StringBuilder(); + connection.OutputReceived += (_, data) => + { + lock (output) + { + output.Append(Encoding.UTF8.GetString(data.Span)); + if (output.ToString().Contains("READY", StringComparison.Ordinal)) ready.TrySetResult(); + } + }; + await connection.StartAsync( + "stty -echo -icanon; printf READY; sleep 30", + Environment.CurrentDirectory, 80, 24, TestContext.Current.CancellationToken); + await ready.Task.WaitAsync(TimeSpan.FromSeconds(5)); + var pending = connection.WriteAsync(new byte[2 * 1024 * 1024], cancellation.Token).AsTask(); + await Task.Delay(100); + Assert.False(pending.IsCompleted); + cancellation.Cancel(); + var error = await Record.ExceptionAsync(() => pending.WaitAsync(TimeSpan.FromSeconds(5))); + Assert.True(error is OperationCanceledException or IOException, error?.ToString()); + await connection.CloseAsync().WaitAsync(TimeSpan.FromSeconds(5)); + Assert.False(connection.IsRunning); + } + + [Fact(Skip = "Unix PTY host is Linux/macOS-only.", SkipUnless = nameof(IsUnix))] + public async Task LargeInputFramesPreserveBytesAndFollowingResize() + { + await using var connection = new LinuxPtyConnection(); + var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var output = new StringBuilder(); + connection.OutputReceived += (_, data) => + { + lock (output) + { + output.Append(Encoding.UTF8.GetString(data.Span)); + var text = output.ToString(); + if (text.Contains("READY", StringComparison.Ordinal)) ready.TrySetResult(); + if (text.Contains("1048576", StringComparison.Ordinal) && + text.Contains("40 100", StringComparison.Ordinal)) completed.TrySetResult(); + } + }; + await connection.StartAsync( + "stty raw -echo; printf READY; head -c 1048576 | wc -c; sleep 1; stty size; sleep 30", + Environment.CurrentDirectory, 80, 24, TestContext.Current.CancellationToken); + await ready.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await connection.WriteAsync(new byte[1024 * 1024]).AsTask().WaitAsync(TimeSpan.FromSeconds(10)); + connection.Resize(100, 40); + await completed.Task.WaitAsync(TimeSpan.FromSeconds(10)); + await connection.CloseAsync().WaitAsync(TimeSpan.FromSeconds(5)); + } + [Fact] public async Task RealPtySupportsInputResizeAndExit() { diff --git a/tests/Devolutions.Terminal.Connection.Tests/OrderedInputWriterTests.cs b/tests/Devolutions.Terminal.Connection.Tests/OrderedInputWriterTests.cs new file mode 100644 index 0000000..7f74269 --- /dev/null +++ b/tests/Devolutions.Terminal.Connection.Tests/OrderedInputWriterTests.cs @@ -0,0 +1,174 @@ +using Devolutions.Terminal.Connection; +using Xunit; + +namespace Devolutions.Terminal.Connection.Tests; + +public sealed class OrderedInputWriterTests +{ + [Fact] + public async Task BlockedWriterDoesNotBlockAdmissionAndPreservesOrder() + { + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var output = new List(); + var writer = new OrderedInputWriter(async (data, token) => + { + await release.Task.WaitAsync(token); + output.AddRange(data.ToArray()); + }, error => Assert.Fail(error.ToString()), CancellationToken.None); + + var first = writer.Enqueue(new byte[] { 1, 2 }); + var second = writer.Enqueue(new byte[] { 3 }); + Assert.False(first.IsCompleted); + Assert.False(second.IsCompleted); + release.SetResult(); + await Task.WhenAll(first, second).WaitAsync(TimeSpan.FromSeconds(5)); + writer.Complete(); + await writer.Completion; + Assert.Equal(new byte[] { 1, 2, 3 }, output); + } + + [Fact] + public async Task ByteLimitRejectsWholeWriteWithoutLosingAcceptedInput() + { + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var written = 0; + var writer = new OrderedInputWriter(async (data, token) => + { + await release.Task.WaitAsync(token); + written += data.Length; + }, error => Assert.Fail(error.ToString()), CancellationToken.None); + var accepted = writer.Enqueue(new byte[OrderedInputWriter.MaximumBytes]); + Assert.Throws(() => { _ = writer.Enqueue(new byte[1]); }); + release.SetResult(); + await accepted.WaitAsync(TimeSpan.FromSeconds(5)); + writer.Complete(); + await writer.Completion; + Assert.Equal(OrderedInputWriter.MaximumBytes, written); + } + + [Fact] + public async Task CancelledPendingWriteIsSkippedWithoutCorruptingNextWrite() + { + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var output = new List(); + var writer = new OrderedInputWriter(async (data, token) => + { + entered.TrySetResult(); + await release.Task.WaitAsync(token); + output.AddRange(data.ToArray()); + }, error => Assert.Fail(error.ToString()), CancellationToken.None); + var first = writer.Enqueue(new byte[] { 1 }); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + using var cancelled = new CancellationTokenSource(); + var second = writer.Enqueue(new byte[] { 2 }, cancelled.Token); + var third = writer.Enqueue(new byte[] { 3 }); + cancelled.Cancel(); + await Assert.ThrowsAnyAsync(() => second.WaitAsync(TimeSpan.FromSeconds(1))); + release.SetResult(); + await Task.WhenAll(first, third).WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.ThrowsAnyAsync(() => second); + writer.Complete(); + await writer.Completion; + Assert.Equal(new byte[] { 1, 3 }, output); + } + + [Fact] + public async Task PendingWriteCountIsBoundedIndependentlyOfBytes() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var writer = new OrderedInputWriter(async (_, token) => + { + entered.TrySetResult(); + await release.Task.WaitAsync(token); + }, error => Assert.Fail(error.ToString()), CancellationToken.None); + var first = writer.Enqueue(new byte[1]); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + var queued = Enumerable.Range(0, 256).Select(_ => writer.Enqueue(new byte[1])).ToArray(); + Assert.Throws(() => { _ = writer.Enqueue(new byte[1]); }); + release.SetResult(); + writer.Complete(); + await Task.WhenAll(queued.Append(first)).WaitAsync(TimeSpan.FromSeconds(5)); + await writer.Completion; + } + + [Fact] + public async Task CancellingInFlightWriteInterruptsSessionAndNeverStartsNextFrame() + { + using var cancellation = new CancellationTokenSource(); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var interrupts = 0; + var writes = 0; + var writer = new OrderedInputWriter(async (_, token) => + { + writes++; + entered.TrySetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, token); + }, _ => { }, CancellationToken.None, () => interrupts++); + var first = writer.Enqueue(new byte[1], cancellation.Token); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + var second = writer.Enqueue(new byte[1]); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => first); + await Assert.ThrowsAnyAsync(() => second); + await writer.Completion.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(1, interrupts); + Assert.Equal(1, writes); + } + + [Fact] + public async Task CancellationAfterDeliveryDoesNotInterruptSession() + { + using var cancellation = new CancellationTokenSource(); + var interrupts = 0; + var writer = new OrderedInputWriter((_, _) => ValueTask.CompletedTask, + error => Assert.Fail(error.ToString()), CancellationToken.None, () => interrupts++); + await writer.Enqueue(new byte[1], cancellation.Token); + cancellation.Cancel(); + writer.Complete(); + await writer.Completion.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(0, interrupts); + } + + [Fact] + public async Task ShutdownInterruptsBlockedWriteAndReportsUndeliveredInput() + { + using var lifetime = new CancellationTokenSource(); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Exception? reported = null; + var writer = new OrderedInputWriter(async (_, token) => + { + entered.TrySetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, token); + }, error => reported = error, lifetime.Token); + var first = writer.Enqueue(new byte[1]); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + var second = writer.Enqueue(new byte[1]); + lifetime.Cancel(); + await Assert.ThrowsAnyAsync(() => first); + await Assert.ThrowsAnyAsync(() => second); + await writer.Completion.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.IsType(reported); + } + + [Fact] + public async Task TransportFailureFailsAllQueuedWritesAndRejectsFurtherInput() + { + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Exception? reported = null; + var writer = new OrderedInputWriter(async (_, token) => + { + await release.Task.WaitAsync(token); + throw new IOException("disconnected"); + }, error => reported = error, CancellationToken.None); + var first = writer.Enqueue(new byte[] { 1 }); + var second = writer.Enqueue(new byte[] { 2 }); + release.SetResult(); + await Assert.ThrowsAsync(() => first); + await Assert.ThrowsAsync(() => second); + await writer.Completion.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.IsType(reported); + Assert.Throws(() => { _ = writer.Enqueue(new byte[] { 3 }); }); + } +} diff --git a/tests/Devolutions.Terminal.Control.Tests/TermControlAccessibilityTests.cs b/tests/Devolutions.Terminal.Control.Tests/TermControlAccessibilityTests.cs index a50bd8f..9f5f382 100644 --- a/tests/Devolutions.Terminal.Control.Tests/TermControlAccessibilityTests.cs +++ b/tests/Devolutions.Terminal.Control.Tests/TermControlAccessibilityTests.cs @@ -232,7 +232,7 @@ public void PasteWarningContractAllowsSubscriberToCancel() WarnAboutLargePaste = false, }; - Assert.Equal(TerminalPasteResult.NoConnection, control.PasteText("one\ntwo", options)); + Assert.Equal(TerminalPasteResult.Cancelled, control.PasteText("one\ntwo", options)); control.PasteWarning += (_, args) => args.Allow = false; Assert.Equal(TerminalPasteResult.Cancelled, control.PasteText("one\ntwo", options)); diff --git a/tests/Devolutions.Terminal.Control.Tests/TermControlPasteTests.cs b/tests/Devolutions.Terminal.Control.Tests/TermControlPasteTests.cs index 6bb9b78..af4f492 100644 --- a/tests/Devolutions.Terminal.Control.Tests/TermControlPasteTests.cs +++ b/tests/Devolutions.Terminal.Control.Tests/TermControlPasteTests.cs @@ -14,6 +14,88 @@ public sealed class TermControlPasteTests { private const string Esc = "\u001b"; + [AvaloniaFact] + public async Task WarningWithoutHandlerFailsClosed() + { + var connection = new RecordingConnection(); + var control = new TermControl { ConnectionFactory = _ => connection }; + await control.StartAsync(new ProfileSettings { Commandline = "cmd.exe" }, 80, 24); + try + { + Assert.Equal(TerminalPasteResult.Cancelled, control.PasteText("first\nsecond")); + Assert.Equal(TerminalPasteResult.Cancelled, await control.PasteTextAsync(new string('x', 6000))); + Assert.Equal("", connection.WrittenText); + } + finally + { + await control.CloseAsync(); + } + } + + [AvaloniaTheory] + [InlineData(true)] + [InlineData(false)] + public async Task AsyncConfirmationGatesPaste(bool accepted) + { + var connection = new RecordingConnection(); + var control = new TermControl { ConnectionFactory = _ => connection }; + await control.StartAsync(new ProfileSettings { Commandline = "cmd.exe" }, 80, 24); + try + { + var decision = new TaskCompletionSource(); + control.ConfirmPasteAsync = request => + { + Assert.Equal(2, request.LineCount); + return decision.Task; + }; + var paste = control.PasteTextAsync("first\nsecond"); + Assert.Equal("", connection.WrittenText); + decision.SetResult(accepted); + Assert.Equal(accepted ? TerminalPasteResult.Written : TerminalPasteResult.Cancelled, await paste); + Assert.Equal(accepted ? "first\rsecond" : "", connection.WrittenText); + } + finally + { + await control.CloseAsync(); + } + } + + [AvaloniaFact] + public async Task ClosingWhileConfirmationIsOpenDoesNotWrite() + { + var connection = new RecordingConnection(); + var control = new TermControl { ConnectionFactory = _ => connection }; + await control.StartAsync(new ProfileSettings { Commandline = "cmd.exe" }, 80, 24); + var decision = new TaskCompletionSource(); + control.ConfirmPasteAsync = _ => decision.Task; + var paste = control.PasteTextAsync("first\nsecond"); + await control.CloseAsync(); + decision.SetResult(true); + Assert.Equal(TerminalPasteResult.NoConnection, await paste); + Assert.Equal("", connection.WrittenText); + } + + [AvaloniaFact] + public async Task RejectedInputReportsErrorRatherThanSuccess() + { + var connection = new RecordingConnection { RejectInput = true }; + var control = new TermControl { ConnectionFactory = _ => connection }; + var errors = 0; + control.InteractionError += (_, _) => errors++; + await control.StartAsync(new ProfileSettings { Commandline = "cmd.exe" }, 80, 24); + try + { + Assert.Equal(TerminalPasteResult.InputRejected, control.PasteText("echo hi")); + control.WriteInput("x"); + Assert.Equal(2, errors); + Assert.Equal("", connection.WrittenText); + } + finally + { + await control.CloseAsync(); + } + } + [AvaloniaFact] public async Task WriteInputSendsLiteralBytesEvenInBracketedPasteMode() { @@ -108,6 +190,7 @@ public async Task PasteTextTrimsTrailingWhitespaceWhenNotBracketed() private sealed class RecordingConnection : IRestartableTerminalConnection { private readonly MemoryStream _written = new(); + public bool RejectInput { get; init; } #pragma warning disable CS0067 public event EventHandler>? OutputReceived; @@ -151,6 +234,11 @@ public Task StartAsync( public void Write(ReadOnlySpan data) { + if (RejectInput) + { + throw new IOException("Input queue is full."); + } + lock (_written) { _written.Write(data); diff --git a/tests/Devolutions.Terminal.Settings.Editor.Tests/SettingsEditorViewModelTests.cs b/tests/Devolutions.Terminal.Settings.Editor.Tests/SettingsEditorViewModelTests.cs index 9495bb3..5ebc8a5 100644 --- a/tests/Devolutions.Terminal.Settings.Editor.Tests/SettingsEditorViewModelTests.cs +++ b/tests/Devolutions.Terminal.Settings.Editor.Tests/SettingsEditorViewModelTests.cs @@ -2,6 +2,7 @@ using Avalonia.Automation; using Avalonia.Controls; using Avalonia.Headless.XUnit; +using Avalonia.LogicalTree; using Devolutions.Terminal.Settings; using Devolutions.Terminal.Settings.Editor.Controls; using Xunit; @@ -305,6 +306,37 @@ public void WindowConstructsWithCompiledXaml() Assert.Same(viewModel, window.DataContext); } + [AvaloniaTheory] + [InlineData(SettingsPage.Interaction, "Detect URLs")] + [InlineData(SettingsPage.Appearance, "Disable animations")] + public void UnsupportedTogglesAreDisabledAndExplained(SettingsPage page, string header) + { + var editor = CreateEditor(); + editor.SelectPage(page); + var view = new SettingsView(editor); + var content = Assert.Single(view.DataTemplates, template => template.Match(editor.CurrentPage)).Build(editor.CurrentPage)!; + content.DataContext = editor.CurrentPage; + var row = Assert.Single(content.GetLogicalDescendants().OfType(), + row => row.Header == header); + Assert.Contains("Not supported", row.Description, StringComparison.Ordinal); + Assert.False(Assert.IsType(row.Value).IsEnabled); + } + + [AvaloniaFact] + public void UnsupportedMeasurementChoicesAreDisabledWithoutChangingSavedValues() + { + var settings = SettingsLoader.Load(Defaults, + """{ "compatibility.textMeasurement": "wcswidth", "compatibility.ambiguousWidth": "wide" }"""); + var editor = CreateEditor(() => settings); + editor.SelectPage(SettingsPage.Compatibility); + var view = new SettingsView(editor); + var content = Assert.Single(view.DataTemplates, template => template.Match(editor.CurrentPage)).Build(editor.CurrentPage)!; + content.DataContext = editor.CurrentPage; + Assert.Equal(2, content.GetLogicalDescendants().OfType().Count(combo => !combo.IsEnabled)); + Assert.Equal("wcswidth", settings.TextMeasurement); + Assert.Equal("wide", settings.AmbiguousWidth); + } + [AvaloniaFact] public void SettingsToggleShowsStateTextAndRoundTripsBinding() { diff --git a/tests/Devolutions.Terminal.UI.Tests/TerminalConfirmationDialogTests.cs b/tests/Devolutions.Terminal.UI.Tests/TerminalConfirmationDialogTests.cs new file mode 100644 index 0000000..360f93b --- /dev/null +++ b/tests/Devolutions.Terminal.UI.Tests/TerminalConfirmationDialogTests.cs @@ -0,0 +1,62 @@ +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Interactivity; +using Avalonia.VisualTree; +using Devolutions.Terminal.App.Views; +using Xunit; + +namespace Devolutions.Terminal.UI.Tests; + +public sealed class TerminalConfirmationDialogTests +{ + [AvaloniaTheory] + [InlineData(true)] + [InlineData(false)] + public async Task DialogRequiresExplicitAcceptance(bool accept) + { + var owner = new Window(); + owner.Show(); + try + { + var service = new TerminalConfirmationDialog(); + var result = service.ShowAsync(owner, "Confirm paste", "Commands may execute.", "Paste"); + Assert.False(result.IsCompleted); + var dialog = Assert.Single(owner.OwnedWindows); + if (accept) + { + var button = Assert.Single(dialog.GetVisualDescendants().OfType