diff --git a/.github/workflows/build-terminal.yml b/.github/workflows/build-terminal.yml index 1d8956c..16e1152 100644 --- a/.github/workflows/build-terminal.yml +++ b/.github/workflows/build-terminal.yml @@ -34,12 +34,11 @@ jobs: path: | artifacts/tools artifacts/ghostty-src - native/ghostty - native/linux-pty + native/ghostty/*/* + native/linux-pty/*/dt-pty-host native/noto-emoji/NotoColorEmoji.ttf - key: native-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/ghostty/ghostty-upstream.json', 'native/linux-pty/dt-pty-host.c', 'native/noto-emoji/noto-emoji.json') }} - restore-keys: | - native-${{ runner.os }}-${{ runner.arch }}- + # Never restore native sources or reuse binaries built from different inputs. + key: native-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/**/*.ps1', 'native/ghostty/ghostty-upstream.json', 'native/linux-pty/dt-pty-host.c', 'native/noto-emoji/noto-emoji.json') }} - name: Set up .NET uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 @@ -74,13 +73,10 @@ jobs: path: | artifacts/tools artifacts/ghostty-src - native/ghostty - native/linux-pty + native/ghostty/*/* + native/linux-pty/*/dt-pty-host native/noto-emoji/NotoColorEmoji.ttf - native/noto-emoji/NotoColorEmoji.ttf - key: native-${{ runner.os }}-${{ runner.arch }}-${{ matrix.rid }}-${{ hashFiles('native/ghostty/ghostty-upstream.json', 'native/linux-pty/dt-pty-host.c', 'native/noto-emoji/noto-emoji.json') }} - restore-keys: | - native-${{ runner.os }}-${{ runner.arch }}- + key: native-v2-${{ runner.os }}-${{ runner.arch }}-${{ matrix.rid }}-${{ hashFiles('native/**/*.ps1', 'native/ghostty/ghostty-upstream.json', 'native/linux-pty/dt-pty-host.c', 'native/noto-emoji/noto-emoji.json') }} - name: Set up .NET uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 @@ -115,12 +111,10 @@ jobs: path: | artifacts/tools artifacts/ghostty-src - native/ghostty - native/linux-pty + native/ghostty/*/* + native/linux-pty/*/dt-pty-host native/noto-emoji/NotoColorEmoji.ttf - key: native-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/ghostty/ghostty-upstream.json', 'native/linux-pty/dt-pty-host.c', 'native/noto-emoji/noto-emoji.json') }} - restore-keys: | - native-${{ runner.os }}-${{ runner.arch }}- + key: native-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/**/*.ps1', 'native/ghostty/ghostty-upstream.json', 'native/linux-pty/dt-pty-host.c', 'native/noto-emoji/noto-emoji.json') }} - name: Set up .NET uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 @@ -149,12 +143,10 @@ jobs: path: | artifacts/tools artifacts/ghostty-src - native/ghostty - native/linux-pty + native/ghostty/*/* + native/linux-pty/*/dt-pty-host native/noto-emoji/NotoColorEmoji.ttf - key: native-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/ghostty/ghostty-upstream.json', 'native/linux-pty/dt-pty-host.c', 'native/noto-emoji/noto-emoji.json') }} - restore-keys: | - native-${{ runner.os }}-${{ runner.arch }}- + key: native-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/**/*.ps1', 'native/ghostty/ghostty-upstream.json', 'native/linux-pty/dt-pty-host.c', 'native/noto-emoji/noto-emoji.json') }} - name: Set up .NET uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 @@ -184,12 +176,10 @@ jobs: path: | artifacts/tools artifacts/ghostty-src - native/ghostty - native/linux-pty + native/ghostty/*/* + native/linux-pty/*/dt-pty-host native/noto-emoji/NotoColorEmoji.ttf - key: native-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/ghostty/ghostty-upstream.json', 'native/linux-pty/dt-pty-host.c', 'native/noto-emoji/noto-emoji.json') }} - restore-keys: | - native-${{ runner.os }}-${{ runner.arch }}- + key: native-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/**/*.ps1', 'native/ghostty/ghostty-upstream.json', 'native/linux-pty/dt-pty-host.c', 'native/noto-emoji/noto-emoji.json') }} - name: Set up .NET uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 @@ -603,4 +593,3 @@ jobs: gh release upload "v${RELEASE_VERSION}" artifacts/release/* --clobber - 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