Git

Vala: история найденных багов

Здесь собраны реальные ошибки, найденные при разработке Niti, Atomic Booster и плагинов. У каждой записи три части: в чём проблема, как нашли и как исправили. Файл с этой историей ведётся при каждом новом баге, чтобы не наступать на одни и те же грабли. Теория и шаблоны на страницах память и замыкания, GTK4 и сигналы и D-Bus и плагины.

Обёртка без владельца: пропавшие подписки#

Проблема. Компоненты быстрого меню (шапка, плитки, слайдеры, панель уведомлений) создавались как локальные переменные в конструкторе модуля. Сам виджет Gtk.Widget жил в дереве GTK, а Vala-обёртка вокруг него с подписками на сервис питания уничтожалась почти сразу после выхода из конструктора. Часть интерфейса переставала обновляться.

Причина. Обёртка и виджет это два разных объекта с раздельными счётчиками. Живость виджета не удерживает обёртку.

Исправление. Обёртки сделаны полями класса-владельца. Правило: класс, созданный ради подписок или контроллеров жестов, всегда поле, а не локальная переменная. Подробности и код на странице память и замыкания.

Таскбар падал при клике по окну#

Проблема. При клике по окну в таскбаре панель завершалась аварийно.

Причина. В функции создания строки окна параметр-делегат был объявлен без owned. Вложенная лямбда захватывала его и вызывала позже, когда исходная память уже была освобождена.

Исправление. Исправлено выбором правильного владения для делегата. Общий вывод: владение делегатом это развилка в обе стороны. Поле owned грозит циклом, параметр без owned, который захватывается и вызывается позже, грозит обращением к освобождённой памяти.

Цикл в обработчике уведомлений#

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

Причина. Функция-переключатель создавалась как лямбда внутри метода экземпляра. Такая лямбда неявно захватывает this, а её блок данных становится целью самого делегата. Освободить его было некому.

Исправление. Переключатель вынесен в статический метод make_group_toggle (panel, group_key, collapsed, expanded) с явными weak-параметрами. Статический метод не захватывает экземпляр и не создаёт самоссылку. Результат проверен по сгенерированному C-коду: остался один обычный ref и unref без цикла.

// Схематично. Было: лямбда в методе экземпляра, неявно захватывает this VoidFn toggle_fn = () => { // использует поля панели }; // Схематично. Стало: статический метод с явными weak-параметрами static VoidFn make_group_toggle (NotificationsPanel panel, string group_key, Gtk.Widget collapsed, Gtk.Widget expanded) { weak NotificationsPanel weak_panel = panel; weak Gtk.Widget weak_collapsed = collapsed; weak Gtk.Widget weak_expanded = expanded; return () => { if (weak_panel == null) return; // работа через weak_* }; }

Объект в собственном обработчике#

Проблема. В быстром меню утекало всё дерево карточек группы при каждой перестройке: около 5 МБ на уведомление, около 3,3 ГБ на 600 уведомлений.

Причина. Vala кладёт все переменные области, захваченные любой лямбдой, в один блок данных со счётчиком. Если контроллер или виджет упомянут в собственном обработчике, получается цикл «объект, обработчик, замыкание, блок, объект». Этот же блок держит и всё остальное, что захватили соседние лямбды.

// Плохо: контроллер упомянут в собственном обработчике click.pressed.connect (() => { click.set_state (Gtk.EventSequenceState.CLAIMED); }); popover.closed.connect (() => popover.unparent ());

Исправление. Внутри обработчика используем только weak-копию, а сильная переменная нужна лишь вне лямбды, для add_controller или append:

weak Gtk.GestureClick weak_click = click; click.pressed.connect (() => { weak_click.set_state (Gtk.EventSequenceState.CLAIMED); });

Исключение: Gtk.NativeDialog. GTK4 его сам не держит, поэтому нужна ссылка в поле на время показа, её сбрасывают в обработчике response. Для таких случаев в проекте есть тест на самозахват (tests/test_closure_self_capture.py), а проверка на живой панели такая: отправить 200 уведомлений и убедиться, что память выходит на плато.

Анимация масштаба над текстом копит память#

Проблема. Память панели росла примерно на 150 КБ при каждом открытии окна поиска.

Причина. CSS-анимация transform: translateY(16px) scale(0.98) над блоком с текстом. Каждый кадр текст рендерится в новом дробном масштабе, кэш глифов GSK пополняется. У быстрого меню с одним сдвигом без масштаба рост был ровным, без утечки.

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

FileStream.write писал мусор#

Проблема. Файлы настроек (appgrid, ярлыки рабочего стола, список расширений, хранилище доверия) иногда портились: после JSON в файл попадал мусор из памяти, и следующий разбор отвергал конфиг.

Причина. Второй аргумент FileStream.write (buf, size) это не длина, а размер элемента. Вызов fwrite (buf, size, buf.length) записывает размер × длина байт. При вызове stream.write (data.data, data.length) писалось длина в квадрате.

// Плохо: пишет длина*длина байт stream.write (data.data, data.length); // Хорошо: размер по умолчанию 1, проверка короткой записи if (stream.write (data.data) != data.data.length) throw new IOError.FAILED ("short write");

Исправление. Поправлены все места записи, добавлен сторожевой тест tests/test_filestream_write_guard.py.

Строка с \x в литерале Vala#

Проблема. Вход в почтовый аккаунт через XOAUTH2 перестал работать: Gmail отвечал AUTHENTICATIONFAILED.

Причина. В литерале "\x01auth" последовательность \x в сгенерированном коде C жадно съедает все следующие шестнадцатеричные цифры, и \x01a превращается в один мусорный байт.

Исправление. Строки с управляющими байтами собираем через StringBuilder.append_c ('\x01') или разрываем литерал.

// Плохо var s = "user=" + user + "\x01auth=Bearer " + token; // Хорошо var sb = new StringBuilder (); sb.append ("user=" + user); sb.append_c ('\x01'); sb.append ("auth=Bearer " + token);

Быстрая проверка по исходникам: grep -n '\\x[0-9a-fA-F]\{2\}[0-9a-fA-F]'.

Одна замыкающая лямбда держит соседей#

Проблема. Обработчики кнопок во всплывающих окнах панели (Vitals, systeminfo, disks) захватывали this без weak.

Что выяснили. Исправление на weak внесено как защитная мера, но измерение не подтвердило, что именно оно давало рост памяти. Тест с GLib.WeakRef на шести объектах дерева за 200 циклов показал ноль утечек и до, и после правки. Цикл через кнопку разрывался раньше, в обработчике closed самой кнопки панели.

Урок. Код с замыканиями стоит исправлять по правилам, но утечкой считать только то, что подтверждено измерением. Первое объяснение часто неверно.

Ложные тесты: проверка без проверки#

Две истории о том, как тест молча ничего не проверял:

  • Изолированный запуск панели в тесте не получил собственную сессионную шину D-Bus и унаследовал шину рабочего стола. Приложение увидело уже работающую боевую панель, стало вторичным экземпляром, и все вызовы D-Bus отвечала именно боевая панель, а не тестовая. Числа менялись из-за реальной работы пользователя. Правило: у любого headless-теста, который трогает D-Bus, обязательно своя dbus-daemon и проверка, что идентификатор процесса в ответе совпадает с тестовым.
  • Команда niti --toggle-popover возвращала код 0 при любом ответе сервера, даже при ERR unknown popover. Тест проверял только код выхода и 180 раз «успешно» ничего не измерил. Правило: разбирать текст ответа, а не только код.

Кроме того, переменную GOBJECT_DEBUG=instance-count нужно устанавливать до запуска процесса, а не изнутри main (): GObject читает её в конструкторе загрузчика. Единственный рабочий способ изнутри это execv на самого себя после установки переменной.

Ночной стенд: что показали 7 часов замеров#

Стенд прогнал около 5000 открытий и закрытий окон за 12 раундов:

  • Циклы открытия и закрытия окон не текут. После стартового подъёма с 44 до 75 МБ наклон составил около 0,3 МБ на раунд в 440 циклов, число файловых дескрипторов и потоков не росло.
  • В боевых сессиях память росла ступенями по 10 и более МБ, и эти ступени по размеру сопоставимы с буфером экрана. Среди кандидатов: плавная смена темы (снимок экрана, преобразование в картинку и текстуру) и всплывающие окна датчиков.
  • Из 20 часов работы панели 11 часов без единого клика дали прирост всего 0,4 МБ: фоновые таймеры исключены как причина. Весь рост приходился на короткие периоды активности с уведомлениями и быстрым меню.
  • Большая часть прироста у быстрого меню оказалась ожидаемым удержанием ограниченной истории (до 50 уведомлений), а не бесконечной утечкой. Настоящей утечкой оказался описанный выше цикл в переключателе группы.

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

Блокировка главного потока#

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

Что нашли. Сохранение истории уведомлений (до 8 МБ) выполнялось синхронным File.replace_contents () прямо в потоке интерфейса. Перенесено на replace_contents_async (). Синхронный вариант оставлен только на завершении работы, где нужна гарантия записи.

Оговорка по метрике. Показатели p95 и p99 измеряют интервал между отрисовками, а не время обработки кадра. Пауза, когда рисовать было нечего, тоже попадает в выборку, поэтому большое значение само по себе не доказывает подвисание.

Грабли тестов жизненного цикла#

Что выяснилось при написании тестов времени жизни объектов:

  • GLib.WeakRef нельзя возвращать из функции и копировать по значению: адрес регистрируется в GObject, копия приводит к падению. Создавайте и читайте его в одной функции.
  • Object.weak_ref (лямбда) в valac 0.56 приводит к падению, используйте GLib.WeakRef.
  • Цепочка a?.b.c () при a == null всё равно вызывает c () с пустым self. Пишите явную проверку if (a != null) a.b.c ();.
  • Test.init делает предупреждения фатальными: Adw.ActionRow, положенный в Gtk.Box, уронит тест, его место в Gtk.ListBox.
  • Повторный Gtk.init_check () без дисплея в одном процессе приводит к падению, кэшируйте результат.
  • В параллельных тестах meson xvfb-run -a конфликтует за дисплей, помечайте такие тесты is_parallel: false.
  • Замыкания объекта на собственных действиях и обработчиках держат цикл, а dispose до него не доходит. Снимайте их явно: remove_action, disconnect.