Для получения триального ключа
заполните форму ниже
Team License (базовая версия)
Enterprise License (расширенная версия)
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

Запросите информацию о ценах
Новая лицензия
Продление лицензии
--Выберите валюту--
USD
EUR
GBP
RUB
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

Бесплатная лицензия PVS-Studio для специалистов Microsoft MVP
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

Для получения лицензии для вашего открытого
проекта заполните, пожалуйста, эту форму
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

Мне интересно попробовать плагин на:
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

Ваше сообщение отправлено.

Мы ответим вам на


Если вы так и не получили ответ, пожалуйста, проверьте папку
Spam/Junk и нажмите на письме кнопку "Не спам".
Так Вы не пропустите ответы от нашей команды.

>
>
>
Скроллбар, который не смог

Скроллбар, который не смог

19 Мар 2020

Недавно вышла новая версия Windows Terminal. Всё бы ничего, но работоспособность её скроллбара оставляла желать лучшего. Поэтому настало время немного потыкать в него палкой и сыграть на бубне.

0718_WinTerm_ru/image1.png

Что обычно пользователи делают с новой версией любого приложения? Правильно, именно то, что не делали тестеры. Поэтому после недолгого использования терминала по назначению, я начал делать с ним страшные вещи. Хорошо-хорошо, я просто пролил кофе на клавиатуру и случайно зажал <Enter>, когда протирал ее. Что в итоге получилось?

0718_WinTerm_ru/image2.png

Да, выглядит не очень впечатляюще, но не спешите бросать в меня камни. Обратите внимание на правую часть. Попробуйте сперва сами догадаться, что с ней не так. Вот вам скриншот для подсказки:

0718_WinTerm_ru/image3.png

Конечно, название статьи было серьёзным спойлером. :)

Итак, есть проблема со скроллбаром. Переходя на новую строку много раз, после пересечения нижней границы, обычно ожидаешь, что появится скроллбар, и можно будет пролистать наверх. Однако этого не происходит до того момента, как мы не напишем какую-либо команду с выводом чего-либо. Скажем так, поведение странное. Впрочем, это могло быть не так критично, если бы скроллбар работал...

Немного потестировав, я обнаружил, что переход на новую строку не увеличивает буфер. Это делает исключительно вывод команд. Так что вышенаписанная whoami увеличит буфер только на одну строку. Из-за этого со временем мы потеряем много истории, особенно после clear.

Первое, что мне пришло на ум – это воспользоваться нашим анализатором и посмотреть, что он скажет:

0718_WinTerm_ru/image4.png

Вывод, конечно, внушительных размеров, поэтому я воспользуюсь могуществом фильтрации и обрежу всё, кроме содержащего ScrollBar:

0718_WinTerm_ru/image6.png

Не могу сказать, что сообщений много... Хорошо, может быть тогда есть что-либо связанное с буфером?

0718_WinTerm_ru/image8.png

Анализатор не подвел и нашел что-то интересное. Я выделил это предупреждение выше. Давайте посмотрим, что там не так:

V501. There are identical sub-expressions to the left and to the right of the '-' operator: bufferHeight - bufferHeight TermControl.cpp 592

bool TermControl::_InitializeTerminal()
{
  ....
  auto bottom = _terminal->GetViewport().BottomExclusive();
  auto bufferHeight = bottom;

  ScrollBar().Maximum(bufferHeight - bufferHeight); // <= Ошибка тут
  ScrollBar().Minimum(0);
  ScrollBar().Value(0);
  ScrollBar().ViewportSize(bufferHeight);
  ....
}

Этот код сопровождается комментарием: "Set up the height of the ScrollViewer and the grid we're using to fake our scrolling height".

Имитация высоты прокрутки – это, конечно, хорошо, но почему в максимум мы проставляем 0? Обратившись к документации, стало понятно, что код не сильно подозрителен. Не поймите меня неправильно: вычитание переменной из самой себя, конечно, подозрительно, но мы получаем на выходе ноль, который нам не вредит. В любом случае, я попробовал указать в поле Maximum значение по умолчанию (1):

0718_WinTerm_ru/image10.png

Скроллбар появился, но он так же не работает:

0718_WinTerm_ru/image11.png

Если что, то я зажал <Enter> секунд на 30. Видимо проблема не в этом, поэтому оставим как было, разве что, заменив bufferHeightbufferHeight на 0:

bool TermControl::_InitializeTerminal()
{
  ....
  auto bottom = _terminal->GetViewport().BottomExclusive();
  auto bufferHeight = bottom;

  ScrollBar().Maximum(0); // <= Замена случилась тут
  ScrollBar().Minimum(0);
  ScrollBar().Value(0);
  ScrollBar().ViewportSize(bufferHeight);
  ....
}

Итак, к решению проблемы мы не особенно приблизились. За неимением лучшего предлагаю отправиться в дебаг. Сперва мы могли бы поставить точку останова (breakpoint) на измененной строке, но сомневаюсь, что это нам как-то поможет. Поэтому нам нужно сперва найти фрагмент, который отвечает за смещение Viewport'а относительно буфера.

Немного о том, как устроен местный (и, скорее всего, любой другой) скроллбар. Есть у нас один большой буфер, который хранит весь вывод. Для взаимодействия с ним используется какая-либо абстракция для отрисовки на экран, в данном случае – viewport.

Используя эти два примитива, можно понять в чем заключается наша проблема. Переход на новую строку не увеличивает буфер, и из-за этого нам просто некуда подниматься. Следовательно, проблема именно в нем.

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

// This event is explicitly revoked in the destructor: does not need weak_ref
auto onReceiveOutputFn = [this](const hstring str) {
  _terminal->Write(str);
};
_connectionOutputEventToken = _connection.TerminalOutput(onReceiveOutputFn);

После того, как мы настроили выше ScrollBar, мы настраиваем различные callback-функции и выполняем __connection.Start() для нашего новоиспечённого окна. После чего происходит вызов вышеуказанной лямбды. Так как это первый раз, когда мы пишем что-то в буфер, предлагаю начать наш дебаг именно оттуда.

Ставим точку останова внутри лямбды и заглядываем в _terminal:

0718_WinTerm_ru/image12.png

Теперь у нас есть две крайне важных для нас переменных – _buffer и _mutableViewport. Поставим на них точки останова и найдём, где они меняются. Правда, с _viewport я немного схитрю и поставлю точку останова не на саму переменную, а на ее поле top (оно нам как раз и нужно).

Теперь нажимаем на <F5>, и ничего не происходит... Хорошо, тогда давайте нажмём пару десятков раз <Enter>. Ничего не произошло. Судя по всему, на _buffer мы поставили точку останова слишком опрометчиво, а _viewport, как и ожидалось, оставался на вершине буфера, который не увеличивался в размере.

В таком случае есть смысл ввести команду, чтобы вызвать обновление вершины _viewport. После этого мы встали на очень любопытном фрагменте кода:

void Terminal::_AdjustCursorPosition(const COORD proposedPosition)
{
  ....
  // Move the viewport down if the cursor moved below the viewport.
  if (cursorPosAfter.Y > _mutableViewport.BottomInclusive())
  {
    const auto newViewTop =
      std::max(0, cursorPosAfter.Y - (_mutableViewport.Height() - 1));
    if (newViewTop != _mutableViewport.Top())
    {
      _mutableViewport = Viewport::FromDimensions(....); // <=
      notifyScroll = true;
    }
  }
  ....
}

Я указал комментарием, где мы остановились. Если посмотреть на комментарий к фрагменту, то становится ясно, что мы близки к решению, как никогда. Именно в этом месте видимая часть смещается относительно буфера, и мы получаем возможность скроллить. Немного понаблюдав за поведением, я заметил один интересный момент: при переходе на новую строку значение переменной cursorPosAfter.Y равно значению viewport'а, поэтому мы его не опускаем, и ничего не работает. К тому же есть аналогичная проблема с переменной newViewTop. Поэтому давайте увеличим значение cursorPosAfter.Y на один и посмотрим, что получилось:

void Terminal::_AdjustCursorPosition(const COORD proposedPosition)
{
  ....
  // Move the viewport down if the cursor moved below the viewport.
  if (cursorPosAfter.Y + 1 > _mutableViewport.BottomInclusive())
  {
    const auto newViewTop =
      std::max(0, cursorPosAfter.Y + 1 - (_mutableViewport.Height() - 1));
    if (newViewTop != _mutableViewport.Top())
    {
      _mutableViewport = Viewport::FromDimensions(....); // <=
      notifyScroll = true;
    }
  }
  ....
}

И результат запуска:

0718_WinTerm_ru/image13.png

Чудеса! Я ввёл n-e количество, и скроллбар работает. Правда, до того момента, как мы введем что-либо... Для демонстрации фейла, я приложу гифку:

0718_WinTerm_ru/image14.gif

Судя по всему, мы делаем несколько лишних переходов на новую строку. Давайте тогда попробуем ограничить наши переходы, при помощи координаты X. Будем смещать строку только тогда, когда X равен 0:

void Terminal::_AdjustCursorPosition(const COORD proposedPosition)
{
  ....
  if (   proposedCursorPosition.X == 0
      && proposedCursorPosition.Y == _mutableViewport.BottomInclusive())
  {
    proposedCursorPosition.Y++;
  }

  // Update Cursor Position
  сursor.SetPosition(proposedCursorPosition);

  const COORD cursorPosAfter = cursor.GetPosition();

  // Move the viewport down if the cursor moved below the viewport.
  if (cursorPosAfter.Y > _mutableViewport.BottomInclusive())
  {
    const auto newViewTop =
      std::max(0, cursorPosAfter.Y - (_mutableViewport.Height() - 1));
    if (newViewTop != _mutableViewport.Top())
    {
      _mutableViewport = Viewport::FromDimensions(....);
      notifyScroll = true;
    }
  }
  ....
}

Фрагмент написанный выше будет смещать координату Y для курсора. После чего мы обновляем позицию курсора. В теории это должно сработать... Что у нас вышло?

0718_WinTerm_ru/image15.gif

Ну, уже, конечно, лучше. Однако есть проблема в том, что мы смещаем точку вывода, но при этом не сдвигаем буфер. Поэтому мы видим два вызова одной и той же команды. Может, конечно, показаться, что я знаю, что делаю, но это не так. :)

На этом этапе я решил проверить содержимое буфера, поэтому вернулся к моменту с которого начал дебаг:

// This event is explicitly revoked in the destructor: does not need weak_ref
auto onReceiveOutputFn = [this](const hstring str) {
  _terminal->Write(str);
};
_connectionOutputEventToken = _connection.TerminalOutput(onReceiveOutputFn);

Я поставил точку останова в тоже место, что и в прошлый раз, и начал смотреть содержимое переменной str. Начнём с того, что я видел на моём экране:

0718_WinTerm_ru/image16.png

Как вы думаете, что будет в строке str, когда я нажму <Enter>?

  • Строка "LONG DESCRIPTION".
  • Весь буфер, который мы сейчас видим.
  • Весь буфер, но без первой строки.

Не буду томить – весь буфер, но без первой строки. И это немалая проблема, так как именно из-за этого мы и теряем историю, причем точечно. Вот так будет выглядеть наш фрагмент вывода help после перехода на новую строку:

0718_WinTerm_ru/image17.png

Стрелочкой я отметил место, где был "LONG DESCRIPTOIN". Может быть тогда перезаписывать буфер со смещением на одну строку? Это бы сработало, если бы этот callback вызывался не на каждый чих.

Я обнаружил как минимум три ситуации, когда он вызывается,

  • Когда мы вводим любой символ;
  • Когда мы двигаемся по истории;
  • Когда мы выполняем команду.

Проблема в том, что смещать буфер нужно только тогда, когда мы выполняем команду, или же вводим <Enter>. В остальных случаях делать это – плохая идея. Значит нам нужно как-то определить внутри, что нужно сместить.

Заключение

0718_WinTerm_ru/image18.png

Эта статья была попыткой показать, как ловко PVS-Studio смог найти дефектный код, приводящий к замеченной мной ошибке. Сообщение на тему вычитания переменной самой из себя меня сильно мотивировало, и я бодро приступил к написанию текста. Но как видите, моя радость была преждевременной и всё оказалось гораздо сложнее.

Поэтому я решил остановиться. Можно было бы ещё потратить пару вечерков, но чем дольше я этим занимался, тем больше и больше проблем возникало. Всё, что я могу сделать, так это пожелать удачи разработчикам Windows Terminal в исправлении этого бага. :)

Надеюсь, я не сильно разочаровал читателя, что так и не довёл исследования до конца и ему было интересно вместе со мной совершить прогулку по внутренностям проекта. В качестве компенсации, я предлагаю воспользоваться промокодом #WindowsTerminal, благодаря которому вы получите демонстрационную версию PVS-Studio не на неделю, а сразу на месяц. Если вы ещё не пробовали статический анализатор PVS-Studio в деле, это хороший повод как раз это сделать. Просто введите "#WindowsTerminal" в поле "Message" на странице загрузки.

А ещё, пользуясь случаем, хочу напомнить, что скоро появится версия C# анализатора, работающая под Linux и macOS. И уже сейчас можно записаться на предварительное тестирование.

Популярные статьи по теме
Любите статический анализ кода!

Дата: 16 Окт 2017

Автор: Андрей Карпов

Я в шоке от возможностей статического анализа кода, хотя сам участвую в разработке инструмента PVS-Studio. На днях я был искренне удивлён тому, что анализатор оказался умнее и внимательнее меня.
Как и почему статические анализаторы борются с ложными срабатываниями

Дата: 20 Мар 2017

Автор: Андрей Карпов

В своей предыдущей статье я писал, что мне не нравится подход, при котором статические анализаторы кода оцениваются с помощью синтетических тестов. В статье приводился пример, воспринимаемый анализат…
PVS-Studio для Java

Дата: 17 Янв 2019

Автор: Андрей Карпов

В седьмой версии статического анализатора PVS-Studio мы добавили поддержку языка Java. Пришло время немного рассказать, как мы начинали делать поддержку языка Java, что у нас получилось и какие дальн…
PVS-Studio ROI

Дата: 30 Янв 2019

Автор: Андрей Карпов

Время от времени нам задают вопрос, какую пользу в денежном эквиваленте получит компания от использования анализатора PVS-Studio. Мы решили оформить ответ в виде статьи и привести таблицы, которые по…
Бесплатный PVS-Studio для тех, кто развивает открытые проекты

Дата: 22 Дек 2018

Автор: Андрей Карпов

В канун празднования нового 2019 года команда PVS-Studio решила сделать приятный подарок всем контрибьюторам open-source проектов, хостящихся на GitHub, GitLab или Bitbucket. Им предоставляется возмо…
Главный вопрос программирования, рефакторинга и всего такого

Дата: 14 Апр 2016

Автор: Андрей Карпов

Вы угадали, ответ - "42". Здесь приводится 42 рекомендации по программированию, которые помогут избежать множества ошибок, сэкономить время и нервы. Автором рекомендаций выступает Андрей Карпов - тех…
Зло живёт в функциях сравнения

Дата: 19 Май 2017

Автор: Андрей Карпов

Возможно, читатели помнят мою статью под названием "Эффект последней строки". В ней идёт речь о замеченной мной закономерности: ошибка чаще всего допускается в последней строке однотипных блоков текс…
Как PVS-Studio оказался внимательнее, чем три с половиной программиста

Дата: 22 Окт 2018

Автор: Андрей Карпов

PVS-Studio, как и другие статические анализаторы кода, часто выдаёт ложные срабатывания. Но не стоит спешить считать странные срабатывания ложными. Это короткая история о том, как PVS-Studio вновь ок…
Технологии, используемые в анализаторе кода PVS-Studio для поиска ошибок и потенциальных уязвимостей

Дата: 21 Ноя 2018

Автор: Андрей Карпов

Краткое описание технологий, используемых в инструменте PVS-Studio, которые позволяют эффективно обнаруживать большое количество паттернов ошибок и потенциальных уязвимостей. Статья описывает реализа…
Статический анализ как часть процесса разработки Unreal Engine

Дата: 27 Июн 2017

Автор: Андрей Карпов

Проект Unreal Engine развивается - добавляется новый код и изменятся уже написанный. Неизбежное следствие развития проекта - появление в коде новых ошибок, которые желательно выявлять как можно раньш…

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

Следующие комментарии
Этот сайт использует куки и другие технологии, чтобы предоставить вам более персонализированный опыт. Продолжая просмотр страниц нашего веб-сайта, вы принимаете условия использования этих файлов. Если вы не хотите, чтобы ваши данные обрабатывались, пожалуйста, покиньте данный сайт. Подробнее →
Принять