Что нужно для взаимодействия с операционной системой исключительно через клавиатуру? Это вопрос, на который каждый разработчик даст свой ответ, и как на него ответили Microsoft, выпустив Windows Terminal?

Введение

Windows Terminal заменила "Узел консоли Windows" в Windows 11 Sun Valley 2 (версия 22H2). Установить программу можно и на предыдущие версии Windows, но не старше Windows 10 May 2020 Update (версия 2004). Первое бросающееся в глаза отличие от традиционной оболочки командной строки — наличие вкладок и другой шрифт по умолчанию, Cascadia Mono. Его можно встретить в современных версиях Visual Studio Code и Visual Studio 2022.

Утверждение о замене собой conhost.exe было очень смелым с моей стороны. Windows Terminal "улучшает" его подобно Киберлюдям (или ассимилирует как Борги, если угодно), и в итоге современное внешне приложение с Direct3D и Quake-режимом на деле оказывается хорошо замаскированным железобетонным "легаси". Если вы контрибьютор ReactOS и боитесь нажимать на ссылку, то уточню, что находящийся по ней исходный код безопасен для ваших глаз — узел консоли Windows лицензируется по разрешительной лицензии MIT, и тому есть подтверждение.

Усаживайтесь поудобнее перед монитором с вашим любимым виртуальным терминалом (а вдруг вы из текстового браузера эту статью читаете?), PVS-Studio выходит на сцену.

Установка PVS-Studio

Загрузить установочный дистрибутив можно с этой страницы. Для запуска анализа понадобится лицензия, но процесс получения триальной версии не заставит вас долго держать руки на клавиатуре и мыши. Установка анализатора тоже не займёт много времени: достаточно только одной дополнительной интеграции для Visual Studio 2022. Актуальная на момент написания статьи версия PVS-Studio — 7.37.

Поскольку мы сегодня говорим о виртуальном терминале, не зазорно будет упомянуть о возможности установить PVS-Studio в автоматическом режиме, используя дополнительные параметры командной строки :)

Конфигурация сборки и анализа

Анализ производится на коммите fc0a06c в "релизной" (Release) конфигурации. После проверки CPython таким способом во мне разгорелся азарт, и организм потребовал ещё больше проверок обычных программ в поставке для конечного пользователя. Или, как бы сказал Fenris из Steel Hunters: "Я всё ещё голоден... приведи мне второго".

И снова, поскольку мы говорим о виртуальном терминале, этот этап будет описываться в двух сценариях — в консольном через PVS-Studio_Cmd.exe и графическом через Visual Studio 2022.

Если работаете через консоль, все необходимые зависимости можно установить через конфигурационный файл WinGet, находящийся в папке .config. Если вы пользуетесь редакцией Community, для вас подойдёт уже предложенная в README.md команда:

winget configure .config\configuration.winget

Если идти через Visual Studio, то при открытии решения вы встретите окно, которое предложит установить недостающие рабочие нагрузки в ваш экземпляр среды разработки. Тем не менее, не связанные с Visual Studio компоненты придётся установить вручную, PowerShell 7 среди них.

Анализ найденных ошибок

При сборке Windows Terminal восстанавливаются пакеты NuGet, загружается несколько vcpkg пакетов и создаётся автоматически генерируемый код. Всё это надо убрать с глаз анализатора долой, чтобы не отвлекаться от кода компонентов непосредственно приложения. Для этого нужно убрать из анализа файлы, путь до которых совпадает с этими масками путей:

\terminal\oss\
\terminal\obj\*\vcpkg\
\terminal\packages\Microsoft.Windows.ImplementationLibrary*\
\Generated Files\

Чтобы исключить эти пути при анализе через консольную утилиту PVS-Studio_Cmd, нужно создать конфигурационный файл .pvsconfig и перечислить их через параметр //V_EXCLUDE_PATH. Далее по тексту созданный файл я буду называть wt.pvsconfig. Он расположен в папке terminal, в корневой директории исходного кода Windows Terminal.

Для исключения путей через плагин для Visual Studio 2022 откройте соответствующую настройку: Extensions > PVS-Studio > Options > Don't Check Files и добавьте их в секцию PathMasks или задействуйте файл wt.pvsconfig, добавив его как элемент решения (по умолчанию Shift+Alt+A), и тогда не придётся править настройки анализатора. Я бы перечислил все возможные способы внедрения файла конфигурации, но документация это сделает за меня :)

И теперь начинаем анализ. Запуск через консольную утилиту PVS-Studio_Cmd.exe для анализа MSBuild проектов выглядит так:

PVS-Studio_Cmd.exe -t D:\Git\terminal\OpenConsole.sln ^
                   -c Release -p x64 ^
                   -C D:\Git\terminal\wt.pvsconfig

А из Visual Studio — вот так:

По готовности PVS-Studio_Cmd.exe сохранит файл отчёта в формате .plog в той же папке, где находится целевой проект или решение, указанный через параметр -t. Чтобы изменить расположение и название файла отчёта, используйте параметр -o. Его можно сохранить в форматах .plog и .json. О том, как взаимодействовать с отчётом, можно узнать в нашей документации. Плагин для Visual Studio по окончании анализа автоматически отобразит его результаты в таблице в нижней части окна.

Внезапно, C#

Я забыл вас предупредить, что проект двуязычный, и здесь чисто случайно оказалось немного управляемого кода. WinUI 3 — виновник этого события, ведь всё, что вокруг чёрной пелены виртуального терминала, сделано на нём. Теперь я вас предупредил — моя совесть чиста. Мы здесь ненадолго и обязательно вернёмся в C++. Срабатывания парные, и их немного. Обещаю!

Итак, первая двойка срабатываний PVS-Studio, ситуация N1:

V3061 Parameter 'sbiexOriginal' is always rewritten in method body before being used. WinEventTests.cs 404

V3061 Parameter 'sbiex' is always rewritten in method body before being used. WinEventTests.cs 409

private void TestScrollByOverflowImpl(
    CmdApp app, ViewportArea area, IntPtr hConsole,
    WinCon.CONSOLE_SCREEN_BUFFER_INFO_EX sbiex,
    Queue<EventData> expected,
    WinCon.CONSOLE_SCREEN_BUFFER_INFO_EX sbiexOriginal
)
{
    // Get original screen information
    sbiexOriginal = app.GetScreenBufferInfo();                  // <=
    short promptLineEnd = sbiexOriginal.dwCursorPosition.X;
    promptLineEnd--; // prompt line ended one position left of cursor

    // Resize the window to only have two lines left at the bottom
    // to test overflow when we echo some text
    sbiex = sbiexOriginal;                                      // <=
    ....
}

Инцидент произошёл в выключенном unit-тесте. Этот случай покрыт соответствующим issue в репозитории. Автор пишет, что он "выключил проваливающиеся тесты, их надо починить и вернуть в работу". Предполагаю, я им дал подсказку, но не утверждаю.

Разбавим классикой от PVS-Studio, ситуация N2:

V3115 Passing 'null' to 'Equals' method should not result in 'NullReferenceException'. WinEventTests.cs 65

public override bool Equals(object obj)
{
    if (typeof(EventData) == obj.GetType())
    {
        return Equals((EventData)obj);
    }
    else
    {
        return base.Equals(obj);
    }
}

obj может быть чем угодно, и даже null. И если он будет null, то мы не попадём в ветку else, а совершенно законно получим торт NRE в лицо. Избежать сюрприза можно небольшим исправлением метода:

if (obj == null)
    return false;

if (typeof(EventData) == obj.GetType())
{
    return Equals((EventData)obj);
}

Ситуация N3:

V3137 The 'fSuccess' variable is assigned but is not used by the end of the function. Program2.cs 80

public static void enableVT()
{
    IntPtr hCon = Pinvoke.GetStdHandle(Pinvoke.STD_OUTPUT_HANDLE);

    int mode;
    bool fSuccess = Pinvoke.GetConsoleMode(hCon, out mode);

    if (fSuccess)
    {
        mode |= Pinvoke.ENABLE_VIRTUAL_TERMINAL_PROCESSING;
        fSuccess = Pinvoke.SetConsoleMode(hCon, mode);           // <=
    }
}

И к нему в придачу метод disableVT() с таким же срабатыванием, не иначе как "копипаста":

  • V3137 The 'fSuccess' variable is assigned but is not used by the end of the function. Program2.cs 92

Функция SetConsoleMode из Windows API действительно возвращает булево значение в зависимости от успешности установки параметров консоли, но заморачиваться с выяснением причины неудачной установки параметров не стали, либо... это копипаста с вызова GetConsoleMode, потому что обе функции возвращают bool, у обеих функций одинаковый набор и порядок параметров. Полагаю, что копипаста возникла в связи с отсутствием необходимости поддерживать предыдущие версии Windows и Windows 10 Threshold 1 (версия 1507), в которых не было поддержки управляющих последовательностей виртуальных терминалов VT100 и им подобных.

Итераторы UBивают

Управляемый код кончился, а поэтому, как я обещал, переходим к неуправляемому. Не сбавляем скорость — летим прямой наводкой в C++!

Предупреждение PVS-Studio:

V783 [CERT-CTR51-CPP] Dereferencing of the invalid iterator 'shades.end()' might take place. ColorHelper.cpp 194

winrt::Windows::UI::Color ColorHelper::GetAccentColor(
    const winrt::Windows::UI::Color& color
)
{
    ....
    auto shades = std::map<float, HSL>();
    ....

    // 3f is quite nice if the whole non-client area is painted
    constexpr auto readability = 1.75f;
    for (auto shade : shades)
    {
        if (shade.first >= readability)
        {
            return HslToRgb(shade.second);
        }
    }
    return HslToRgb(shades.end()->second);    // <=
}

Возможно ли, что ни один оттенок не будет соответствовать критерию читабельности? Точно сказать нельзя, но такая ситуация вполне вероятна. Здесь отлично читается неопределённое поведение, и психобумагу предъявлять не надо для убедительности, потому что разыменование std::map::end() приводит именно к нему, так как этот итератор указывает на элемент следующий за последним в std::map.

Телепаты в отпуске

Предупреждение PVS-Studio:

V1004 [CERT-EXP34-C] The 'pSettings' pointer was used unsafely after it was verified against nullptr. Check lines: 199, 211. window.cpp 211

[[nodiscard]] NTSTATUS Window::_MakeWindow(
    _In_ Settings* const pSettings,
    _In_ SCREEN_INFORMATION* const pScreen
)
{
    auto& g = ServiceLocator::LocateGlobals();
    auto& gci = g.getConsoleInformation();
    auto status = STATUS_SUCCESS;

    if (pSettings == nullptr)                   <=
    {
        status = STATUS_INVALID_PARAMETER_1;
    }
    ....

    const auto useDx = pSettings->GetUseDx();   <=
    try
    {
#if TIL_FEATURE_CONHOSTATLASENGINE_ENABLED
        if (useDx)
        {
            pAtlasEngine = new AtlasEngine();
            g.pRender->AddRenderEngine(pAtlasEngine);
        }
        else
#endif
        ....
    }    
    catch (...)
    {
       status = NTSTATUS_FROM_HRESULT(wil::ResultFromCaughtException());
    }
    ....
}

Итак, здесь банальное разыменование нулевого указателя. Как это сюда попало? Разбираем по шагам. У нас есть параметр pSettings, который может быть nullptr. Мы проверяем это и устанавливаем соответствующий статус.

if (pSettings == nullptr)
{
    status = STATUS_INVALID_PARAMETER_1;
}

Дальше начинается самое интересное: мы разыменовываем nullptr, и затем пытаемся воспользоваться данными, полученными из nullptr. Вы могли напрячься, так как это чистейший ERROR_ACCESS_VIOLATION. Падение произойдёт именно на этой строке:

const auto useDx = pSettings->GetUseDx();

И нет, заворачивание этой операции в блок try-catch не поможет, потому что разыменование нулевого указателя не покрывается обработкой исключений, и в C++ не существует NPE. Кто-то сейчас скажет, что есть флаг /EHa и возможность перехватывать все исключения с помощью catch(...), но даже Microsoft отговаривает от его использования:

Specifying /EHa and trying to handle all exceptions by using catch(...) can be dangerous. In most cases, asynchronous exceptions are unrecoverable and should be considered fatal. Catching them and proceeding can cause process corruption and lead to bugs that are hard to find and fix.

Even though Windows and Visual C++ support SEH, we strongly recommend that you use ISO-standard C++ exception handling (/EHsc or /EHs). It makes your code more portable and flexible.

Дальше по коду идёт "обработка" исключения путём обновления значения статуса в переменной status и пропуск операций, если бы программу не ждал успех.

catch (...)
{
    status = NTSTATUS_FROM_HRESULT(wil::ResultFromCaughtException());
}

if (SUCCEEDED_NTSTATUS(status))
{
    ....
}
return status;

Жаль, что это не так работает... вернее, никак не работает.

COM в горле

Предупреждение PVS-Studio:

V1114 Suspicious use of 'static_cast' when working with COM interfaces. Consider using the 'QueryInterface' member function. uiaTextRange.cpp 84

namespace Microsoft::Console::Types
{
    class ScreenInfoUiaProviderBase :
        public WRL::RuntimeClass<
            WRL::RuntimeClassFlags<WRL::ClassicCom | WRL::InhibitFtmBase>,
            IRawElementProviderSimple,
            IRawElementProviderFragment,
            ITextProvider
        >,
        public IUiaTraceable
    ....
}

namespace Microsoft::Console::Interactivity::Win32
{
    class ScreenInfoUiaProvider final :
        public Microsoft::Console::Types::ScreenInfoUiaProviderBase
    {
        ....
    }
    ....
}

HWND UiaTextRange::_getWindowHandle() const
{
    const auto provider = static_cast<ScreenInfoUiaProvider*>(_pProvider); // <=
    return provider->GetWindowHandle();
}

Windows Runtime C++ Template Library, она же WRL, содержит в себе функциональность для работы с COM-интерфейсами. Если требуется задействовать интерфейс, надо сначала поинтересоваться, а есть ли до него доступ, и точно ли будет возвращён нужный интерфейс. "Грубое" обращение к ним через преобразование указателей не обеспечивает ни того, ни другого, как и контроля количества ссылок на объект с этим интерфейсом. Поэтому нужно использовать QueryInterface(), и тогда COM перестанет быть вашим монстром под кроватью, пугающим вас в ночи. Но здесь нас поджидал сюрприз... для ScreenInfoUiaProvider не был выделен IID, и вызов QueryInterface(), чтобы корректно получить интерфейс, становится невозможным!

Если ваша экспертиза в COM сильнее моей, и вы можете прокомментировать происходящее, не откажусь от пояснений, но по мне — это кошмар :)

Симуляция бурной деятельности

Сообщение PVS-Studio:

V519 [CERT-MSC13-C] The 'delayedLineBreak' variable is assigned values twice successively. Perhaps this is a mistake. Check lines: 2938, 2941. textBuffer.cpp 2941

void TextBuffer::_SerializeRow(
    const ROW& row, const til::CoordType startX,
    const til::CoordType endX, const bool addLineBreak,
    const bool isLastRow, std::wstring& buffer,
    std::optional<TextAttribute>& previousTextAttr, bool& delayedLineBreak
) const
{
    ....
    // Handle empty rows (with no runs).
    // See above for more details about `delayedLineBreak`.
    if (delayedLineBreak)
    {
        buffer.append(L"\r\n");
        delayedLineBreak = false;
    }

    delayedLineBreak = !row.WasWrapForced() && addLineBreak;
    ....
}

Напрасное изменение значения переменной delayedLineBreak, ведь буквально следующей операцией его опять поменяют. На ревью это не выявили. Даже намётанный глаз самого опытного разработчика может "замылиться" и пропустить неприятную мелочь, а статическому анализатору усталость не свойственна. PVS-Studio может проверять код, приходящий через pull request'ы, и в случае Windows Terminal это можно делать не отходя от кассы — прямо через GitHub Actions!

C из себя не выкинешь

Предупреждение PVS-Studio:

V668 [CERT-MEM52-CPP] There is no sense in testing the 'pszTranslatedConsoleTitle' pointer against null, as the memory was allocated using the 'new' operator. The exception will be generated in the case of memory allocation error. srvinit.cpp 657

PWSTR TranslateConsoleTitle(_In_ PCWSTR pwszConsoleTitle,
                            const BOOL fUnexpand,
                            const BOOL fSubstitute)
{
  ....
  LPWSTR pszTranslatedConsoleTitle;
  const auto cbTranslatedConsoleTitle = cbSystemRoot + cbConsoleTitle;
  Tmp = pszTranslatedConsoleTitle = (PWSTR)new BYTE[cbTranslatedConsoleTitle];
  if (pszTranslatedConsoleTitle == nullptr)
  {
      return nullptr;
  }
  ....
}

Читается почерк C-программиста, недавно пришедшего в C++. Сила привычки взяла верх и заставила пальцы рук написать проверку на нулевой указатель — вдруг память не выделилась. Если используется оператор new[], это делать не надо, потому что неудавшееся выделение памяти надо ловить через try-catch.

try
{
    Tmp = pszTranslatedConsoleTitle = (PWSTR)new BYTE[cbTranslatedConsoleTitle];
}
catch (std::bad_alloc)
{
    return nullptr;
}

Поскольку такой обработки здесь нет, увы, программу ждёт exitus letalis, если память будет неоткуда взять.

Жить будет?

Будет и очень долго! Заголовок навевает драму. Если бы прогнозы были бы такими же, как он, проект Windows Terminal закрыли бы, и Microsoft не стали бы дальше его развивать. Он не просто выжил, он быстро занял место приложения терминала по умолчанию, поставляясь вместе с Windows 11.

У одних постепенное открытие исходного кода компонентов Windows вызывает восторг, у других — настороженность, у третьих — интерес. Ну а я просто наблюдаю за жизнью... багов. PVS-Studio в этом помогает — с его помощью время их существования сокращается в разы, и если у вас есть проект с открытым исходным кодом, вы тоже можете почувствовать всю мощь статического анализатора, получив лицензию для OSS-проектов совершенно бесплатно.

Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Taras Shevchenko. Windows Terminal proves to be terminal?

Комментарии (2)


  1. warkid
    28.07.2025 13:43

    И это все проблемы, что нашлись? Так-то немного, нет?


    1. x86chk Автор
      28.07.2025 13:43

      Не все, но и описывать все в одной статье было бы избыточно.
      Вы можете также сами попробовать найти что-нибудь в исходном коде, воспользовавшись триальной лицензией :)