From 040f26175f6f2b200fe3d920d9354c05962c7a92 Mon Sep 17 00:00:00 2001 From: Leonard Hecker Date: Fri, 23 Aug 2024 22:02:01 +0200 Subject: [PATCH] Use DECCRA/DECFRA for ScrollConsoleScreenBuffer (#17747) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This adds logic to get the DA1 report from the hosting terminal on startup. We then use the information to figure out if it supports rectangular area operations. If so, we can use DECCRA/DECFRA to implement ScrollConsoleScreenBuffer. This additionally changes `ScrollConsoleScreenBuffer` to always forbid control characters as the fill character, even in conhost (via `VtIo::SanitizeUCS2`). My hope is that this makes the API more consistent and robust as it avoids another source for invisible control characters in the text buffer. Part of #17643 ## Validation Steps Performed * New tests ✅ --- .github/actions/spelling/expect/expect.txt | 21 +- src/host/VtInputThread.cpp | 4 +- src/host/VtInputThread.hpp | 7 +- src/host/VtIo.cpp | 109 +++++---- src/host/VtIo.hpp | 5 +- src/host/getset.cpp | 132 ++++++++--- src/host/ut_host/VtIoTests.cpp | 223 +++++++++++++++++- src/inc/til/enumset.h | 7 + .../parser/InputStateMachineEngine.cpp | 54 ++++- .../parser/InputStateMachineEngine.hpp | 36 ++- src/types/viewport.cpp | 6 +- src/winconpty/ft_pty/ConPtyTests.cpp | 80 ++----- tools/OpenConsole.psm1 | 2 +- tools/tests.xml | 1 + 14 files changed, 505 insertions(+), 182 deletions(-) diff --git a/.github/actions/spelling/expect/expect.txt b/.github/actions/spelling/expect/expect.txt index fc5bb6d9027..1621856787d 100644 --- a/.github/actions/spelling/expect/expect.txt +++ b/.github/actions/spelling/expect/expect.txt @@ -9,7 +9,6 @@ ABORTIFHUNG ACCESSTOKEN acidev ACIOSS -ACover acp actctx ACTCTXW @@ -87,6 +86,7 @@ Autowrap AVerify awch azurecr +AZZ backgrounded Backgrounder backgrounding @@ -180,7 +180,6 @@ CFuzz cgscrn chafa changelists -charinfo CHARSETINFO chh chshdng @@ -264,7 +263,6 @@ consolegit consolehost CONSOLEIME consoleinternal -Consoleroot CONSOLESETFOREGROUND consoletaeftemplates consoleuwp @@ -386,7 +384,7 @@ DECCIR DECCKM DECCKSR DECCOLM -DECCRA +deccra DECCTR DECDC DECDHL @@ -398,7 +396,7 @@ DECEKBD DECERA DECFI DECFNK -DECFRA +decfra DECGCI DECGCR DECGNL @@ -727,7 +725,6 @@ GHIJKL gitcheckin gitfilters gitlab -gitmodules gle GLOBALFOCUS GLYPHENTRY @@ -1021,7 +1018,6 @@ lstatus lstrcmp lstrcmpi LTEXT -LTLTLTLTL ltsc LUID luma @@ -1116,7 +1112,6 @@ msrc MSVCRTD MTSM Munged -munges murmurhash muxes myapplet @@ -1218,7 +1213,6 @@ ntlpcapi ntm ntrtl ntstatus -NTSYSCALLAPI nttree nturtl ntuser @@ -1526,7 +1520,6 @@ rftp rgbi RGBQUAD rgbs -rgci rgfae rgfte rgn @@ -1604,6 +1597,7 @@ SELECTALL SELECTEDFONT SELECTSTRING Selfhosters +Serbo SERVERDLL SETACTIVE SETBUDDYINT @@ -1832,8 +1826,6 @@ TOPDOWNDIB TOpt tosign touchpad -Tpp -Tpqrst tracelogging traceviewpp trackbar @@ -1958,7 +1950,6 @@ VPACKMANIFESTDIRECTORY VPR VREDRAW vsc -vsconfig vscprintf VSCROLL vsdevshell @@ -2000,7 +1991,6 @@ wcswidth wddm wddmcon WDDMCONSOLECONTEXT -WDK wdm webpage websites @@ -2074,7 +2064,6 @@ winuserp WINVER wistd wmain -wmemory WMSZ wnd WNDALLOC @@ -2173,6 +2162,7 @@ yact YCast YCENTER YCount +yizz YLimit YPan YSubstantial @@ -2186,3 +2176,4 @@ ZCtrl ZWJs ZYXWVU ZYXWVUTd +zzf diff --git a/src/host/VtInputThread.cpp b/src/host/VtInputThread.cpp index efd2fd4e51e..3c359a3c8e5 100644 --- a/src/host/VtInputThread.cpp +++ b/src/host/VtInputThread.cpp @@ -185,8 +185,8 @@ void VtInputThread::_InputThread() return S_OK; } -void VtInputThread::WaitUntilDSR(DWORD timeout) const noexcept +til::enumset VtInputThread::WaitUntilDA1(DWORD timeout) const noexcept { const auto& engine = static_cast(_pInputStateMachine->Engine()); - engine.WaitUntilDSR(timeout); + return engine.WaitUntilDA1(timeout); } diff --git a/src/host/VtInputThread.hpp b/src/host/VtInputThread.hpp index e6f15e2ab68..50405b5a255 100644 --- a/src/host/VtInputThread.hpp +++ b/src/host/VtInputThread.hpp @@ -18,13 +18,18 @@ Author(s): namespace Microsoft::Console { + namespace VirtualTerminal + { + enum class DeviceAttribute : uint64_t; + } + class VtInputThread { public: VtInputThread(_In_ wil::unique_hfile hPipe, const bool inheritCursor); [[nodiscard]] HRESULT Start(); - void WaitUntilDSR(DWORD timeout) const noexcept; + til::enumset WaitUntilDA1(DWORD timeout) const noexcept; private: static DWORD WINAPI StaticVtInputThreadProc(_In_ LPVOID lpParameter); diff --git a/src/host/VtIo.cpp b/src/host/VtIo.cpp index a45ec8260a3..026010b2f70 100644 --- a/src/host/VtIo.cpp +++ b/src/host/VtIo.cpp @@ -163,38 +163,37 @@ bool VtIo::IsUsingVt() const { Writer writer{ this }; + // MSFT: 15813316 + // If the terminal application wants us to inherit the cursor position, + // we're going to emit a VT sequence to ask for the cursor position. + // If we get a response, the InteractDispatch will call SetCursorPosition, + // which will call to our VtIo::SetCursorPosition method. + // + // By sending the request before sending the DA1 one, we can simply + // wait for the DA1 response below and effectively wait for both. + if (_lookingForCursorPosition) + { + writer.WriteUTF8("\x1b[6n"); // Cursor Position Report (DSR CPR) + } + // GH#4999 - Send a sequence to the connected terminal to request // win32-input-mode from them. This will enable the connected terminal to // send us full INPUT_RECORDs as input. If the terminal doesn't understand // this sequence, it'll just ignore it. - writer.WriteUTF8( + "\x1b[c" // DA1 Report (Primary Device Attributes) "\x1b[?1004h" // Focus Event Mode "\x1b[?9001h" // Win32 Input Mode ); - // MSFT: 15813316 - // If the terminal application wants us to inherit the cursor position, - // we're going to emit a VT sequence to ask for the cursor position, then - // wait 1s until we get a response. - // If we get a response, the InteractDispatch will call SetCursorPosition, - // which will call to our VtIo::SetCursorPosition method. - if (_lookingForCursorPosition) - { - writer.WriteUTF8("\x1b[6n"); // Cursor Position Report (DSR CPR) - } - writer.Submit(); } - if (_lookingForCursorPosition) { - _lookingForCursorPosition = false; - // Allow the input thread to momentarily gain the console lock. auto& gci = ServiceLocator::LocateGlobals().getConsoleInformation(); const auto suspension = gci.SuspendLock(); - _pVtInputThread->WaitUntilDSR(3000); + _deviceAttributes = _pVtInputThread->WaitUntilDA1(3000); } if (_pPtySignalInputThread) @@ -211,6 +210,16 @@ bool VtIo::IsUsingVt() const return S_OK; } +void VtIo::SetDeviceAttributes(const til::enumset attributes) noexcept +{ + _deviceAttributes = attributes; +} + +til::enumset VtIo::GetDeviceAttributes() const noexcept +{ + return _deviceAttributes; +} + // Method Description: // - Create our pseudo window. This is exclusively called by // ConsoleInputThreadProcWin32 on the console input thread. @@ -359,6 +368,40 @@ void VtIo::FormatAttributes(std::wstring& target, const TextAttribute& attribute target.append(bufW, len); } +wchar_t VtIo::SanitizeUCS2(wchar_t ch) +{ + // If any of the values in the buffer are C0 or C1 controls, we need to + // convert them to printable codepoints, otherwise they'll end up being + // evaluated as control characters by the receiving terminal. We use the + // DOS 437 code page for the C0 controls and DEL, and just a `?` for the + // C1 controls, since that's what you would most likely have seen in the + // legacy v1 console with raster fonts. + if (ch < 0x20) + { + static constexpr wchar_t lut[] = { + // clang-format off + L' ', L'☺', L'☻', L'♥', L'♦', L'♣', L'♠', L'•', L'◘', L'○', L'◙', L'♂', L'♀', L'♪', L'♫', L'☼', + L'►', L'◄', L'↕', L'‼', L'¶', L'§', L'▬', L'↨', L'↑', L'↓', L'→', L'←', L'∟', L'↔', L'▲', L'▼', + // clang-format on + }; + ch = lut[ch]; + } + else if (ch == 0x7F) + { + ch = L'⌂'; + } + else if (ch > 0x7F && ch < 0xA0) + { + ch = L'?'; + } + else if (til::is_surrogate(ch)) + { + ch = UNICODE_REPLACEMENT; + } + + return ch; +} + VtIo::Writer::Writer(VtIo* io) noexcept : _io{ io } { @@ -592,7 +635,7 @@ void VtIo::Writer::WriteUTF16StripControlChars(std::wstring_view str) const for (it = begControlChars; it != end && IsControlCharacter(*it); ++it) { - WriteUCS2StripControlChars(*it); + WriteUCS2(SanitizeUCS2(*it)); } } } @@ -626,36 +669,6 @@ void VtIo::Writer::WriteUCS2(wchar_t ch) const _io->_back.append(buf, len); } -void VtIo::Writer::WriteUCS2StripControlChars(wchar_t ch) const -{ - // If any of the values in the buffer are C0 or C1 controls, we need to - // convert them to printable codepoints, otherwise they'll end up being - // evaluated as control characters by the receiving terminal. We use the - // DOS 437 code page for the C0 controls and DEL, and just a `?` for the - // C1 controls, since that's what you would most likely have seen in the - // legacy v1 console with raster fonts. - if (ch < 0x20) - { - static constexpr wchar_t lut[] = { - // clang-format off - L' ', L'☺', L'☻', L'♥', L'♦', L'♣', L'♠', L'•', L'◘', L'○', L'◙', L'♂', L'♀', L'♪', L'♫', L'☼', - L'►', L'◄', L'↕', L'‼', L'¶', L'§', L'▬', L'↨', L'↑', L'↓', L'→', L'←', L'∟', L'↔', L'▲', L'▼', - // clang-format on - }; - ch = lut[ch]; - } - else if (ch == 0x7F) - { - ch = L'⌂'; - } - else if (ch > 0x7F && ch < 0xA0) - { - ch = L'?'; - } - - WriteUCS2(ch); -} - // CUP: Cursor Position void VtIo::Writer::WriteCUP(til::point position) const { @@ -773,7 +786,7 @@ void VtIo::Writer::WriteInfos(til::point target, std::span info do { - WriteUCS2StripControlChars(ch); + WriteUCS2(SanitizeUCS2(ch)); } while (--repeat); } } diff --git a/src/host/VtIo.hpp b/src/host/VtIo.hpp index 35a0a13bc27..a5d13764261 100644 --- a/src/host/VtIo.hpp +++ b/src/host/VtIo.hpp @@ -35,7 +35,6 @@ namespace Microsoft::Console::VirtualTerminal void WriteUTF16TranslateCRLF(std::wstring_view str) const; void WriteUTF16StripControlChars(std::wstring_view str) const; void WriteUCS2(wchar_t ch) const; - void WriteUCS2StripControlChars(wchar_t ch) const; void WriteCUP(til::point position) const; void WriteDECTCEM(bool enabled) const; void WriteSGR1006(bool enabled) const; @@ -54,6 +53,7 @@ namespace Microsoft::Console::VirtualTerminal static void FormatAttributes(std::string& target, const TextAttribute& attributes); static void FormatAttributes(std::wstring& target, const TextAttribute& attributes); + static wchar_t SanitizeUCS2(wchar_t ch); [[nodiscard]] HRESULT Initialize(const ConsoleArguments* const pArgs); [[nodiscard]] HRESULT CreateAndStartSignalThread() noexcept; @@ -62,6 +62,8 @@ namespace Microsoft::Console::VirtualTerminal bool IsUsingVt() const; [[nodiscard]] HRESULT StartIfNeeded(); + void SetDeviceAttributes(til::enumset attributes) noexcept; + til::enumset GetDeviceAttributes() const noexcept; void SendCloseEvent(); void CreatePseudoWindow(); @@ -79,6 +81,7 @@ namespace Microsoft::Console::VirtualTerminal std::unique_ptr _pVtInputThread; std::unique_ptr _pPtySignalInputThread; + til::enumset _deviceAttributes; // We use two buffers: A front and a back buffer. The front buffer is the one we're currently // sending to the terminal (it's being "presented" = it's on the "front" & "visible"). diff --git a/src/host/getset.cpp b/src/host/getset.cpp index 80e3431d215..cb3c80d7bbb 100644 --- a/src/host/getset.cpp +++ b/src/host/getset.cpp @@ -13,6 +13,7 @@ #include "_stream.h" #include "../interactivity/inc/ServiceLocator.hpp" +#include "../terminal/parser/InputStateMachineEngine.hpp" #include "../types/inc/convert.hpp" #include "../types/inc/viewport.hpp" @@ -993,29 +994,36 @@ void ApiRoutines::GetLargestConsoleWindowSizeImpl(const SCREEN_INFORMATION& cont { try { + // Just in case if the client application didn't check if this request is useless. + if (source.left == target.x && source.top == target.y) + { + return S_OK; + } + LockConsole(); auto Unlock = wil::scope_exit([&] { UnlockConsole(); }); auto& gci = ServiceLocator::LocateGlobals().getConsoleInformation(); - if (auto writer = gci.GetVtWriterForBuffer(&context)) + auto& buffer = context.GetActiveBuffer(); + const auto bufferSize = buffer.GetBufferSize(); + auto writer = gci.GetVtWriterForBuffer(&context); + + // Applications like to pass 0/0 for the fill char/attribute. + // What they want is the whitespace and current attributes. + if (fillCharacter == UNICODE_NULL && fillAttribute == 0) { - auto& buffer = context.GetActiveBuffer(); + fillAttribute = buffer.GetAttributes().GetLegacyAttributes(); + } - // However, if the character is null and we were given a null attribute (represented as legacy 0), - // then we'll just fill with spaces and whatever the buffer's default colors are. - if (fillCharacter == UNICODE_NULL && fillAttribute == 0) - { - fillCharacter = UNICODE_SPACE; - fillAttribute = buffer.GetAttributes().GetLegacyAttributes(); - } + // Avoid writing control characters into the buffer. + // A null character will get translated to whitespace. + fillCharacter = Microsoft::Console::VirtualTerminal::VtIo::SanitizeUCS2(fillCharacter); + if (writer) + { // GH#3126 - This is a shim for cmd's `cls` function. In the - // legacy console, `cls` is supposed to clear the entire buffer. In - // conpty however, there's no difference between the viewport and the - // entirety of the buffer. We're going to see if this API call exactly - // matched the way we expect cmd to call it. If it does, then - // let's manually emit a Full Reset (RIS). - const auto bufferSize = buffer.GetBufferSize(); + // legacy console, `cls` is supposed to clear the entire buffer. + // We always use a VT sequence, even if ConPTY isn't used, because those are faster nowadays. if (enableCmdShim && source.left <= 0 && source.top <= 0 && source.right >= bufferSize.RightInclusive() && source.bottom >= bufferSize.BottomInclusive() && @@ -1028,36 +1036,96 @@ void ApiRoutines::GetLargestConsoleWindowSizeImpl(const SCREEN_INFORMATION& cont return S_OK; } - const auto clipViewport = clip ? Viewport::FromInclusive(*clip) : bufferSize; + const auto clipViewport = clip ? Viewport::FromInclusive(*clip).Clamp(bufferSize) : bufferSize; const auto sourceViewport = Viewport::FromInclusive(source); - Viewport readViewport; - Viewport writtenViewport; + const auto fillViewport = sourceViewport.Clamp(clipViewport); - const auto w = std::max(0, sourceViewport.Width()); - const auto h = std::max(0, sourceViewport.Height()); - const auto a = static_cast(w * h); - if (a == 0) + writer.BackupCursor(); + + if (gci.GetVtIo()->GetDeviceAttributes().test(Microsoft::Console::VirtualTerminal::DeviceAttribute::RectangularAreaOperations)) { - return S_OK; + // This calculates just the positive offsets caused by out-of-bounds (OOB) source and target coordinates. + // + // If the source rectangle is OOB to the bottom-right, then the size of the rectangle that can + // be copied shrinks, but its origin stays the same. However, if the rectangle is OOB to the + // top-left then the origin of the to-be-copied rectangle will be offset by an inverse amount. + // Similarly, if the *target* rectangle is OOB to the bottom-right, its size shrinks while + // the origin stays the same, and if it's OOB to the top-left, then the origin is offset. + // + // In other words, this calculates the total offset that needs to be applied to the to-be-copied rectangle. + // Later down below we'll then clamp that rectangle which will cause its size to shrink as needed. + const til::point offset{ + std::max(0, -source.left) + std::max(0, clipViewport.Left() - target.x), + std::max(0, -source.top) + std::max(0, clipViewport.Top() - target.y), + }; + + const auto copyTargetViewport = Viewport::FromDimensions(target + offset, sourceViewport.Dimensions()).Clamp(clipViewport); + const auto copySourceViewport = Viewport::FromDimensions(sourceViewport.Origin() + offset, copyTargetViewport.Dimensions()).Clamp(bufferSize); + const auto fills = Viewport::Subtract(fillViewport, copyTargetViewport); + std::wstring buf; + + if (!fills.empty()) + { + Microsoft::Console::VirtualTerminal::VtIo::FormatAttributes(buf, TextAttribute{ fillAttribute }); + } + + if (copySourceViewport.IsValid() && copyTargetViewport.IsValid()) + { + // DECCRA: Copy Rectangular Area + fmt::format_to( + std::back_inserter(buf), + FMT_COMPILE(L"\x1b[{};{};{};{};;{};{}$v"), + copySourceViewport.Top() + 1, + copySourceViewport.Left() + 1, + copySourceViewport.BottomExclusive(), + copySourceViewport.RightExclusive(), + copyTargetViewport.Top() + 1, + copyTargetViewport.Left() + 1); + } + + for (const auto& fill : fills) + { + // DECFRA: Fill Rectangular Area + fmt::format_to( + std::back_inserter(buf), + FMT_COMPILE(L"\x1b[{};{};{};{};{}$x"), + static_cast(fillCharacter), + fill.Top() + 1, + fill.Left() + 1, + fill.BottomExclusive(), + fill.RightExclusive()); + } + + WriteCharsVT(context, buf); } + else + { + const auto w = std::max(0, sourceViewport.Width()); + const auto h = std::max(0, sourceViewport.Height()); + const auto a = static_cast(w * h); + if (a == 0) + { + return S_OK; + } - til::small_vector backup; - til::small_vector fill; + til::small_vector backup; + til::small_vector fill; - backup.resize(a, CHAR_INFO{ fillCharacter, fillAttribute }); - fill.resize(a, CHAR_INFO{ fillCharacter, fillAttribute }); + backup.resize(a, CHAR_INFO{ fillCharacter, fillAttribute }); + fill.resize(a, CHAR_INFO{ fillCharacter, fillAttribute }); - writer.BackupCursor(); + Viewport readViewport; + Viewport writtenViewport; - RETURN_IF_FAILED(ReadConsoleOutputWImplHelper(context, backup, sourceViewport, readViewport)); - RETURN_IF_FAILED(WriteConsoleOutputWImplHelper(context, fill, w, sourceViewport.Clamp(clipViewport), writtenViewport)); - RETURN_IF_FAILED(WriteConsoleOutputWImplHelper(context, backup, w, Viewport::FromDimensions(target, readViewport.Dimensions()).Clamp(clipViewport), writtenViewport)); + RETURN_IF_FAILED(ReadConsoleOutputWImplHelper(context, backup, sourceViewport, readViewport)); + RETURN_IF_FAILED(WriteConsoleOutputWImplHelper(context, fill, w, fillViewport, writtenViewport)); + RETURN_IF_FAILED(WriteConsoleOutputWImplHelper(context, backup, w, Viewport::FromDimensions(target, readViewport.Dimensions()).Clamp(clipViewport), writtenViewport)); + } writer.Submit(); } else { - auto& buffer = context.GetActiveBuffer(); TextAttribute useThisAttr(fillAttribute); ScrollRegion(buffer, source, clip, target, fillCharacter, useThisAttr); } diff --git a/src/host/ut_host/VtIoTests.cpp b/src/host/ut_host/VtIoTests.cpp index f557905bf17..3ad87891a28 100644 --- a/src/host/ut_host/VtIoTests.cpp +++ b/src/host/ut_host/VtIoTests.cpp @@ -4,6 +4,7 @@ #include "precomp.h" #include "CommonState.hpp" +#include "../../terminal/parser/InputStateMachineEngine.hpp" using namespace WEX::Common; using namespace WEX::Logging; @@ -29,6 +30,8 @@ constexpr CHAR_INFO ci_blu(wchar_t ch) noexcept } #define cup(y, x) "\x1b[" #y ";" #x "H" // CUP: Cursor Position +#define deccra(t, l, b, r, y, x) "\x1b[" #t ";" #l ";" #b ";" #r ";;" #y ";" #x "$v" // DECCRA: Copy Rectangular Area +#define decfra(ch, t, l, b, r) "\x1b[" #ch ";" #t ";" #l ";" #b ";" #r "$x" #define decawm(h) "\x1b[?7" #h // DECAWM: Autowrap Mode #define decsc() "\x1b\x37" // DECSC: DEC Save Cursor (+ attributes) #define decrc() "\x1b\x38" // DECRC: DEC Restore Cursor (+ attributes) @@ -554,12 +557,224 @@ class ::Microsoft::Console::VirtualTerminal::VtIoTests actual = readOutput(); VERIFY_ARE_EQUAL(expected, actual); + // Copying from a partially out-of-bounds source to a partially out-of-bounds target, + // while source and target overlap and there's a partially out-of-bounds clip rect. + // + // Before: + // clip rect + // +~~~~~~~~~~~~~~~~~~~~~+ + // +--------------$--------+ $ + // | A Z Z$ b C | D c Y $ + // | $+-------+------------$--+ + // | E z z$| f G | H g Y $ | + // | src $| | $ | + // | i z z$| J d | B E L $ | + // | $| | dst $ | + // | m n M$| N h | F i P $ | + // +--------------$+-------+ $ | + // +~e~~~~~~~~~~~~~~~~~~~+ | + // +-----------------------+ + // + // After: + // + // +-----------------------+ + // | A Z Z y y | D c Y + // | +-------+---------------+ + // | E z z | y A | Z Z b | + // | | | | + // | i z z | y E | z z f | + // | | | | + // | m n M | y i | z z J | + // +---------------+-------+ | + // | | + // +-----------------------+ + THROW_IF_FAILED(routines.ScrollConsoleScreenBufferWImpl(*screenInfo, { -1, 0, 4, 3 }, { 3, 1 }, til::inclusive_rect{ 3, -1, 7, 9 }, L'y', blu, false)); + expected = + decsc() // + cup(1, 4) sgr_blu("yy") // + cup(2, 4) sgr_blu("yy") // + cup(3, 4) sgr_blu("yy") // + cup(4, 4) sgr_blu("yy") // + cup(2, 4) sgr_blu("y") sgr_red("AZZ") sgr_blu("b") // + cup(3, 4) sgr_blu("y") sgr_red("E") sgr_blu("zzf") // + cup(4, 4) sgr_blu("yizz") sgr_red("J") // + decrc(); + actual = readOutput(); + VERIFY_ARE_EQUAL(expected, actual); + + static constexpr std::array expectedContents{ { + // clang-format off + ci_red('A'), ci_red('Z'), ci_red('Z'), ci_blu('y'), ci_blu('y'), ci_red('D'), ci_blu('c'), ci_red('Y'), + ci_red('E'), ci_blu('z'), ci_blu('z'), ci_blu('y'), ci_red('A'), ci_red('Z'), ci_red('Z'), ci_blu('b'), + ci_blu('i'), ci_blu('z'), ci_blu('z'), ci_blu('y'), ci_red('E'), ci_blu('z'), ci_blu('z'), ci_blu('f'), + ci_blu('m'), ci_blu('n'), ci_red('M'), ci_blu('y'), ci_blu('i'), ci_blu('z'), ci_blu('z'), ci_red('J'), + // clang-format on + } }; + std::array actualContents{}; + Viewport actualContentsRead; + THROW_IF_FAILED(routines.ReadConsoleOutputWImpl(*screenInfo, actualContents, Viewport::FromDimensions({}, { 8, 4 }), actualContentsRead)); + VERIFY_IS_TRUE(memcmp(expectedContents.data(), actualContents.data(), sizeof(actualContents)) == 0); + } + + TEST_METHOD(ScrollConsoleScreenBufferW_DECCRA) + { + ServiceLocator::LocateGlobals().getConsoleInformation().GetVtIo()->SetDeviceAttributes({ DeviceAttribute::RectangularAreaOperations }); + const auto cleanup = wil::scope_exit([]() { + ServiceLocator::LocateGlobals().getConsoleInformation().GetVtIo()->SetDeviceAttributes({}); + }); + + std::string_view expected; + std::string_view actual; + + setupInitialContents(); + + // Scrolling from nowhere to somewhere are no-ops and should not emit anything. + THROW_IF_FAILED(routines.ScrollConsoleScreenBufferWImpl(*screenInfo, { 0, 0, -1, -1 }, {}, std::nullopt, L' ', 0, false)); + THROW_IF_FAILED(routines.ScrollConsoleScreenBufferWImpl(*screenInfo, { -10, -10, -9, -9 }, {}, std::nullopt, L' ', 0, false)); + expected = ""; + actual = readOutput(); + VERIFY_ARE_EQUAL(expected, actual); + + // Scrolling from somewhere to nowhere should clear the area. + THROW_IF_FAILED(routines.ScrollConsoleScreenBufferWImpl(*screenInfo, { 0, 0, 1, 1 }, { 10, 10 }, std::nullopt, L' ', red, false)); + expected = + decsc() // + sgr_red() // + decfra(32, 1, 1, 2, 2) // ' ' = 32 + decrc(); + actual = readOutput(); + VERIFY_ARE_EQUAL(expected, actual); + + // cmd uses ScrollConsoleScreenBuffer to clear the buffer contents and that gets translated to a clear screen sequence. + THROW_IF_FAILED(routines.ScrollConsoleScreenBufferWImpl(*screenInfo, { 0, 0, 7, 3 }, { 0, -4 }, std::nullopt, 0, 0, true)); + expected = "\x1b[H\x1b[2J\x1b[3J"; + actual = readOutput(); + VERIFY_ARE_EQUAL(expected, actual); + + // + // A B a b C D c d + // + // E F e f G H g h + // + // i j I J k l K L + // + // m n M N o p O P + // + setupInitialContents(); + + // Scrolling from somewhere to somewhere. + // + // +-------+ + // A | Z Z | b C D c d + // | src | + // E | Z Z | f G H g h + // +-------+ +-------+ + // i j I J k | B a | L + // | dst | + // m n M N o | F e | P + // +-------+ + THROW_IF_FAILED(routines.ScrollConsoleScreenBufferWImpl(*screenInfo, { 1, 0, 2, 1 }, { 5, 2 }, std::nullopt, L'Z', red, false)); + expected = + decsc() // + sgr_red() // + deccra(1, 2, 2, 3, 3, 6) // + decfra(90, 1, 2, 2, 3) // 'Z' = 90 + decrc(); + actual = readOutput(); + VERIFY_ARE_EQUAL(expected, actual); + + // Same, but with a partially out-of-bounds target and clip rect. Clip rects affect both + // the source area that gets filled and the target area that gets a copy of the source contents. + // + // A Z Z b C D c d + // +---+~~~~~~~~~~~~~~~~~~~~~~~+ + // | E $ z z | f G H g $ h + // | $ src | +---$-------+ + // | i $ z z | J k B | E $ L | + // +---$-------+ | $ dst | + // m $ n M N o F | i $ P | + // +~~~~~~~~~~~~~~~~~~~~~~~+-------+ + // clip rect + THROW_IF_FAILED(routines.ScrollConsoleScreenBufferWImpl(*screenInfo, { 0, 1, 2, 2 }, { 6, 2 }, til::inclusive_rect{ 1, 1, 6, 3 }, L'z', blu, false)); + expected = + decsc() // + sgr_blu() // + deccra(2, 1, 3, 1, 3, 7) // + decfra(122, 2, 2, 3, 3) // 'z' = 122 + decrc(); + actual = readOutput(); + VERIFY_ARE_EQUAL(expected, actual); + + // Same, but with a partially out-of-bounds source. + // The boundaries of the buffer act as a clip rect for reading and so only 2 cells get copied. + // + // +-------+ + // A Z Z b C D c | Y | + // | src | + // E z z f G H g | Y | + // +---+ +-------+ + // i z z J | d | B E L + // |dst| + // m n M N | h | F i P + // +---+ + THROW_IF_FAILED(routines.ScrollConsoleScreenBufferWImpl(*screenInfo, { 7, 0, 8, 1 }, { 4, 2 }, std::nullopt, L'Y', red, false)); + expected = + decsc() // + sgr_red() // + deccra(1, 8, 2, 8, 3, 5) // + decfra(89, 1, 8, 2, 8) // 'Y' = 89 + decrc(); + actual = readOutput(); + VERIFY_ARE_EQUAL(expected, actual); + + // Copying from a partially out-of-bounds source to a partially out-of-bounds target, + // while source and target overlap and there's a partially out-of-bounds clip rect. + // + // Before: + // clip rect + // +~~~~~~~~~~~~~~~~~~~~~+ + // +--------------$--------+ $ + // | A Z Z$ b C | D c Y $ + // | $+-------+------------$--+ + // | E z z$| f G | H g Y $ | + // | src $| | $ | + // | i z z$| J d | B E L $ | + // | $| | dst $ | + // | m n M$| N h | F i P $ | + // +--------------$+-------+ $ | + // +~e~~~~~~~~~~~~~~~~~~~+ | + // +-----------------------+ + // + // After: + // + // +-----------------------+ + // | A Z Z y y | D c Y + // | +-------+---------------+ + // | E z z | y A | Z Z b | + // | | | | + // | i z z | y E | z z f | + // | | | | + // | m n M | y i | z z J | + // +---------------+-------+ | + // | | + // +-----------------------+ + THROW_IF_FAILED(routines.ScrollConsoleScreenBufferWImpl(*screenInfo, { -1, 0, 4, 3 }, { 3, 1 }, til::inclusive_rect{ 3, -1, 7, 9 }, L'y', blu, false)); + expected = + decsc() // + sgr_blu() // + deccra(1, 1, 3, 4, 2, 5) // + decfra(121, 1, 4, 1, 5) // 'y' = 121 + decfra(121, 2, 4, 4, 4) // + decrc(); + actual = readOutput(); + VERIFY_ARE_EQUAL(expected, actual); + static constexpr std::array expectedContents{ { // clang-format off - ci_red('A'), ci_red('Z'), ci_red('Z'), ci_blu('b'), ci_red('C'), ci_red('D'), ci_blu('c'), ci_red('Y'), - ci_red('E'), ci_blu('z'), ci_blu('z'), ci_blu('f'), ci_red('G'), ci_red('H'), ci_blu('g'), ci_red('Y'), - ci_blu('i'), ci_blu('z'), ci_blu('z'), ci_red('J'), ci_blu('d'), ci_red('B'), ci_red('E'), ci_red('L'), - ci_blu('m'), ci_blu('n'), ci_red('M'), ci_red('N'), ci_blu('h'), ci_red('F'), ci_blu('i'), ci_red('P'), + ci_red('A'), ci_red('Z'), ci_red('Z'), ci_blu('y'), ci_blu('y'), ci_red('D'), ci_blu('c'), ci_red('Y'), + ci_red('E'), ci_blu('z'), ci_blu('z'), ci_blu('y'), ci_red('A'), ci_red('Z'), ci_red('Z'), ci_blu('b'), + ci_blu('i'), ci_blu('z'), ci_blu('z'), ci_blu('y'), ci_red('E'), ci_blu('z'), ci_blu('z'), ci_blu('f'), + ci_blu('m'), ci_blu('n'), ci_red('M'), ci_blu('y'), ci_blu('i'), ci_blu('z'), ci_blu('z'), ci_red('J'), // clang-format on } }; std::array actualContents{}; diff --git a/src/inc/til/enumset.h b/src/inc/til/enumset.h index 5ee47a71675..7e7c4bc0cc1 100644 --- a/src/inc/til/enumset.h +++ b/src/inc/til/enumset.h @@ -24,6 +24,13 @@ namespace til // Terminal Implementation Library. Also: "Today I Learned" static_assert(std::is_unsigned_v); public: + static constexpr enumset from_bits(UnderlyingType data) noexcept + { + enumset result; + result._data = data; + return result; + } + // Method Description: // - Constructs a new bitset with the given list of positions set to true. TIL_ENUMSET_VARARG diff --git a/src/terminal/parser/InputStateMachineEngine.cpp b/src/terminal/parser/InputStateMachineEngine.cpp index 1111e74d370..70e3991ee49 100644 --- a/src/terminal/parser/InputStateMachineEngine.cpp +++ b/src/terminal/parser/InputStateMachineEngine.cpp @@ -89,11 +89,6 @@ static bool operator==(const Ss3ToVkey& pair, const Ss3ActionCodes code) noexcep return pair.action == code; } -InputStateMachineEngine::InputStateMachineEngine(std::unique_ptr pDispatch) : - InputStateMachineEngine(std::move(pDispatch), false) -{ -} - InputStateMachineEngine::InputStateMachineEngine(std::unique_ptr pDispatch, const bool lookingForDSR) : _pDispatch(std::move(pDispatch)), _lookingForDSR(lookingForDSR), @@ -102,14 +97,28 @@ InputStateMachineEngine::InputStateMachineEngine(std::unique_ptr InputStateMachineEngine::WaitUntilDA1(DWORD timeout) const noexcept { + uint64_t val = 0; + // atomic_wait() returns false when the timeout expires. // Technically we should decrement the timeout with each iteration, // but I suspect infinite spurious wake-ups are a theoretical problem. - while (_lookingForDSR.load(std::memory_order::relaxed) && til::atomic_wait(_lookingForDSR, true, timeout)) + for (;;) { + val = _deviceAttributes.load(std::memory_order::relaxed); + if (val) + { + break; + } + + if (!til::atomic_wait(_deviceAttributes, val, timeout)) + { + break; + } } + + return til::enumset::from_bits(val); } bool InputStateMachineEngine::EncounteredWin32InputModeSequence() const noexcept @@ -411,13 +420,12 @@ bool InputStateMachineEngine::ActionCsiDispatch(const VTID id, const VTParameter // The F3 case is special - it shares a code with the DeviceStatusResponse. // If we're looking for that response, then do that, and break out. // Else, fall though to the _GetCursorKeysModifierState handler. - if (_lookingForDSR.load(std::memory_order::relaxed)) + if (_lookingForDSR) { _pDispatch->MoveCursor(parameters.at(0), parameters.at(1)); // Right now we're only looking for on initial cursor // position response. After that, only look for F3. - _lookingForDSR.store(false, std::memory_order::relaxed); - til::atomic_notify_all(_lookingForDSR); + _lookingForDSR = false; return true; } // Heuristic: If the hosting terminal used the win32 input mode, chances are high @@ -464,6 +472,32 @@ bool InputStateMachineEngine::ActionCsiDispatch(const VTID id, const VTParameter case CsiActionCodes::FocusOut: _pDispatch->FocusChanged(false); return true; + case CsiActionCodes::DA_DeviceAttributes: + // This assumes that InputStateMachineEngine is tightly coupled with VtInputThread and the rest of the ConPTY system (VtIo). + // On startup, ConPTY will send a DA1 request to get more information about the hosting terminal. + // We catch it here and store the information for later retrieval. + if (_deviceAttributes.load(std::memory_order_relaxed) == 0) + { + til::enumset attributes; + + // The first parameter denotes the conformance level. + if (parameters.at(0).value() >= 61) + { + parameters.subspan(1).for_each([&](auto p) { + attributes.set(static_cast(p)); + return true; + }); + } + + _deviceAttributes.fetch_or(attributes.bits(), std::memory_order_relaxed); + til::atomic_notify_all(_deviceAttributes); + + // VtIo first sends a DSR CPR and then a DA1 request. + // If we encountered a DA1 response here, the DSR request is definitely done now. + _lookingForDSR = false; + return true; + } + return false; case CsiActionCodes::Win32KeyboardInput: { // Use WriteCtrlKey here, even for keys that _aren't_ control keys, diff --git a/src/terminal/parser/InputStateMachineEngine.hpp b/src/terminal/parser/InputStateMachineEngine.hpp index b37b67d4b6e..98f031a3bea 100644 --- a/src/terminal/parser/InputStateMachineEngine.hpp +++ b/src/terminal/parser/InputStateMachineEngine.hpp @@ -49,6 +49,32 @@ namespace Microsoft::Console::VirtualTerminal // CAPSLOCK_ON 0x0080 // ENHANCED_KEY 0x0100 + enum class DeviceAttribute : uint64_t + { + Columns132 = 1, + PrinterPort = 2, + Sixel = 4, + SelectiveErase = 6, + SoftCharacterSet = 7, + UserDefinedKeys = 8, + NationalReplacementCharacterSets = 9, + SerboCroatianCharacterSet = 12, + EightBitInterfaceArchitecture = 14, + TechnicalCharacterSet = 15, + WindowingCapability = 18, + Sessions = 19, + HorizontalScrolling = 21, + Color = 22, + GreekCharacterSet = 23, + TurkishCharacterSet = 24, + RectangularAreaOperations = 28, + TextMacros = 32, + Latin2CharacterSet = 42, + PCTerm = 44, + SoftKeyMapping = 45, + AsciiTerminalEmulation = 46, + }; + enum CsiActionCodes : uint64_t { ArrowUp = VTID("A"), @@ -67,6 +93,7 @@ namespace Microsoft::Console::VirtualTerminal CSI_F3 = VTID("R"), // Both F3 and DSR are on R. // DSR_DeviceStatusReportResponse = VTID("R"), CSI_F4 = VTID("S"), + DA_DeviceAttributes = VTID("?c"), DTTERM_WindowManipulation = VTID("t"), CursorBackTab = VTID("Z"), Win32KeyboardInput = VTID("_") @@ -128,11 +155,9 @@ namespace Microsoft::Console::VirtualTerminal class InputStateMachineEngine : public IStateMachineEngine { public: - InputStateMachineEngine(std::unique_ptr pDispatch); - InputStateMachineEngine(std::unique_ptr pDispatch, - const bool lookingForDSR); + InputStateMachineEngine(std::unique_ptr pDispatch, const bool lookingForDSR = false); - void WaitUntilDSR(DWORD timeout) const noexcept; + til::enumset WaitUntilDA1(DWORD timeout) const noexcept; bool EncounteredWin32InputModeSequence() const noexcept override; @@ -159,7 +184,8 @@ namespace Microsoft::Console::VirtualTerminal private: const std::unique_ptr _pDispatch; - std::atomic _lookingForDSR{ false }; + std::atomic _deviceAttributes{ 0 }; + bool _lookingForDSR = false; bool _encounteredWin32InputModeSequence = false; DWORD _mouseButtonState = 0; std::chrono::milliseconds _doubleClickTime; diff --git a/src/types/viewport.cpp b/src/types/viewport.cpp index ad276f8ecbe..018212c2165 100644 --- a/src/types/viewport.cpp +++ b/src/types/viewport.cpp @@ -603,7 +603,11 @@ try const auto intersection = Viewport::Intersect(original, removeMe); // If there's no intersection, there's nothing to remove. - if (!intersection.IsValid()) + if (!original.IsValid()) + { + // Nothing to do here. + } + else if (!intersection.IsValid()) { // Just put the original rectangle into the results and return early. result.push_back(original); diff --git a/src/winconpty/ft_pty/ConPtyTests.cpp b/src/winconpty/ft_pty/ConPtyTests.cpp index 27cfdecf837..5b2fb672082 100644 --- a/src/winconpty/ft_pty/ConPtyTests.cpp +++ b/src/winconpty/ft_pty/ConPtyTests.cpp @@ -38,6 +38,9 @@ static Pipes createPipes() VERIFY_IS_TRUE(SetHandleInformation(p.our.in.get(), HANDLE_FLAG_INHERIT, 0)); VERIFY_IS_TRUE(SetHandleInformation(p.our.out.get(), HANDLE_FLAG_INHERIT, 0)); + // ConPTY requests a DA1 report on startup. Emulate the response from the terminal. + WriteFile(p.our.in.get(), "\x1b[?61c", 6, nullptr, nullptr); + return p; } @@ -189,25 +192,12 @@ void ConPtyTests::CreateConPtyBadSize() void ConPtyTests::GoodCreate() { PseudoConsole pcon{}; - wil::unique_handle outPipeOurSide; - wil::unique_handle inPipeOurSide; - wil::unique_handle outPipePseudoConsoleSide; - wil::unique_handle inPipePseudoConsoleSide; - - SECURITY_ATTRIBUTES sa; - sa.nLength = sizeof(sa); - sa.bInheritHandle = TRUE; - sa.lpSecurityDescriptor = nullptr; - - VERIFY_IS_TRUE(CreatePipe(inPipePseudoConsoleSide.addressof(), inPipeOurSide.addressof(), &sa, 0)); - VERIFY_IS_TRUE(CreatePipe(outPipeOurSide.addressof(), outPipePseudoConsoleSide.addressof(), &sa, 0)); - VERIFY_IS_TRUE(SetHandleInformation(inPipeOurSide.get(), HANDLE_FLAG_INHERIT, 0)); - VERIFY_IS_TRUE(SetHandleInformation(outPipeOurSide.get(), HANDLE_FLAG_INHERIT, 0)); + auto pipes = createPipes(); VERIFY_SUCCEEDED( _CreatePseudoConsole(defaultSize, - inPipePseudoConsoleSide.get(), - outPipePseudoConsoleSide.get(), + pipes.conpty.in.get(), + pipes.conpty.out.get(), 0, &pcon)); @@ -220,24 +210,12 @@ void ConPtyTests::GoodCreateMultiple() { PseudoConsole pcon1{}; PseudoConsole pcon2{}; - wil::unique_handle outPipeOurSide; - wil::unique_handle inPipeOurSide; - wil::unique_handle outPipePseudoConsoleSide; - wil::unique_handle inPipePseudoConsoleSide; - - SECURITY_ATTRIBUTES sa; - sa.nLength = sizeof(sa); - sa.bInheritHandle = TRUE; - sa.lpSecurityDescriptor = nullptr; - VERIFY_IS_TRUE(CreatePipe(inPipePseudoConsoleSide.addressof(), inPipeOurSide.addressof(), &sa, 0)); - VERIFY_IS_TRUE(CreatePipe(outPipeOurSide.addressof(), outPipePseudoConsoleSide.addressof(), &sa, 0)); - VERIFY_IS_TRUE(SetHandleInformation(inPipeOurSide.get(), HANDLE_FLAG_INHERIT, 0)); - VERIFY_IS_TRUE(SetHandleInformation(outPipeOurSide.get(), HANDLE_FLAG_INHERIT, 0)); + auto pipes = createPipes(); VERIFY_SUCCEEDED( _CreatePseudoConsole(defaultSize, - inPipePseudoConsoleSide.get(), - outPipePseudoConsoleSide.get(), + pipes.conpty.in.get(), + pipes.conpty.out.get(), 0, &pcon1)); auto closePty1 = wil::scope_exit([&] { @@ -246,8 +224,8 @@ void ConPtyTests::GoodCreateMultiple() VERIFY_SUCCEEDED( _CreatePseudoConsole(defaultSize, - inPipePseudoConsoleSide.get(), - outPipePseudoConsoleSide.get(), + pipes.conpty.in.get(), + pipes.conpty.out.get(), 0, &pcon2)); auto closePty2 = wil::scope_exit([&] { @@ -258,23 +236,12 @@ void ConPtyTests::GoodCreateMultiple() void ConPtyTests::SurvivesOnBreakOutput() { PseudoConsole pty = { 0 }; - wil::unique_handle outPipeOurSide; - wil::unique_handle inPipeOurSide; - wil::unique_handle outPipePseudoConsoleSide; - wil::unique_handle inPipePseudoConsoleSide; - SECURITY_ATTRIBUTES sa; - sa.nLength = sizeof(sa); - sa.bInheritHandle = TRUE; - sa.lpSecurityDescriptor = nullptr; - VERIFY_IS_TRUE(CreatePipe(inPipePseudoConsoleSide.addressof(), inPipeOurSide.addressof(), &sa, 0)); - VERIFY_IS_TRUE(CreatePipe(outPipeOurSide.addressof(), outPipePseudoConsoleSide.addressof(), &sa, 0)); - VERIFY_IS_TRUE(SetHandleInformation(inPipeOurSide.get(), HANDLE_FLAG_INHERIT, 0)); - VERIFY_IS_TRUE(SetHandleInformation(outPipeOurSide.get(), HANDLE_FLAG_INHERIT, 0)); + auto pipes = createPipes(); VERIFY_SUCCEEDED( _CreatePseudoConsole(defaultSize, - inPipePseudoConsoleSide.get(), - outPipePseudoConsoleSide.get(), + pipes.conpty.in.get(), + pipes.conpty.out.get(), 0, &pty)); auto closePty1 = wil::scope_exit([&] { @@ -292,7 +259,7 @@ void ConPtyTests::SurvivesOnBreakOutput() VERIFY_IS_TRUE(GetExitCodeProcess(piClient.hProcess, &dwExit)); VERIFY_ARE_EQUAL(dwExit, (DWORD)STILL_ACTIVE); - VERIFY_IS_TRUE(CloseHandle(outPipeOurSide.get())); + pipes.our.out.reset(); // Wait for a couple seconds, make sure the child is still alive. VERIFY_ARE_EQUAL(WaitForSingleObject(pty.hConPtyProcess, 2000), (DWORD)WAIT_TIMEOUT); @@ -317,23 +284,12 @@ void ConPtyTests::DiesOnClose() VERIFY_SUCCEEDED(TestData::TryGetValue(L"commandline", testCommandline), L"Get a commandline to test"); PseudoConsole pty = { 0 }; - wil::unique_handle outPipeOurSide; - wil::unique_handle inPipeOurSide; - wil::unique_handle outPipePseudoConsoleSide; - wil::unique_handle inPipePseudoConsoleSide; - SECURITY_ATTRIBUTES sa; - sa.nLength = sizeof(sa); - sa.bInheritHandle = TRUE; - sa.lpSecurityDescriptor = nullptr; - VERIFY_IS_TRUE(CreatePipe(inPipePseudoConsoleSide.addressof(), inPipeOurSide.addressof(), &sa, 0)); - VERIFY_IS_TRUE(CreatePipe(outPipeOurSide.addressof(), outPipePseudoConsoleSide.addressof(), &sa, 0)); - VERIFY_IS_TRUE(SetHandleInformation(inPipeOurSide.get(), HANDLE_FLAG_INHERIT, 0)); - VERIFY_IS_TRUE(SetHandleInformation(outPipeOurSide.get(), HANDLE_FLAG_INHERIT, 0)); + auto pipes = createPipes(); VERIFY_SUCCEEDED( _CreatePseudoConsole(defaultSize, - inPipePseudoConsoleSide.get(), - outPipePseudoConsoleSide.get(), + pipes.conpty.in.get(), + pipes.conpty.out.get(), 0, &pty)); auto closePty1 = wil::scope_exit([&] { diff --git a/tools/OpenConsole.psm1 b/tools/OpenConsole.psm1 index bbcbc889933..ba87033007b 100644 --- a/tools/OpenConsole.psm1 +++ b/tools/OpenConsole.psm1 @@ -169,7 +169,7 @@ function Invoke-OpenConsoleTests() [switch]$FTOnly, [parameter(Mandatory=$false)] - [ValidateSet('host', 'interactivityWin32', 'terminal', 'adapter', 'feature', 'uia', 'textbuffer', 'til', 'types', 'terminalCore', 'terminalApp', 'localTerminalApp', 'unitSettingsModel', 'unitRemoting', 'unitControl')] + [ValidateSet('host', 'interactivityWin32', 'terminal', 'adapter', 'feature', 'uia', 'textbuffer', 'til', 'types', 'terminalCore', 'terminalApp', 'localTerminalApp', 'unitSettingsModel', 'unitRemoting', 'unitControl', 'winconpty')] [string]$Test, [parameter(Mandatory=$false)] diff --git a/tools/tests.xml b/tools/tests.xml index 4301c09b2a3..9ec3585de6b 100644 --- a/tools/tests.xml +++ b/tools/tests.xml @@ -15,4 +15,5 @@ +