Недавно я завершил процесс устранения бага в веб-браузере Chromium. Это был мой первый опыт контрибьютинга в проект Chromium, да и вообще в какой-либо опенсорсный проект такого масштаба, он сильно отличался от любой моей прошлой работы над open source.
Я решил написать обзор всего процесса с начала до конца, чтобы о нём могли получить представление разработчики, сами стремящиеся к подобной работе.
Заранее скажу, что устранение этого бага стоило всех моих усилий, и я очень горд наличием этого достижения в моём портфолио разработчика ПО.
Баг
Устранённый мной баг находился в инструментах разработчика Chromium (Devtools), в частности, в интеграции между Devtools и сетевыми запросами, выполняемыми ворклетами (worklet), работающими вне основного потока, например, AudioWorklet
.
Выполняемые ворклетами сетевые запросы, в том числе и запросы для получения исходного файла JavaScript точки входа ворклета, полностью игнорировались во вкладке Network инструментов разработчика.
Кроме того, игнорировалась и опция Disable Cache инструментов разработчика. Это раздражало меня сильнее всего, потому что в процессе разработки было невозможно удалить из кэша устаревший код. Чтобы решить эту проблему локально, я реализовал собственные способы очистки кэша, но в продакшене, где мне и нужно было кэширование, всё оказалось сложнее.
Я нашёл три отчёта об ошибках, соответствующих этому багу, причиной которых было одно:
- Модули ворклетов не отображаются в сетевом разделе devtools.
- Опция Disable cache (когда открыты devtools) не влияет на модули audioWorklet.
- Audio Worklet постоянно использует неверный кэшированный *processor.js.
Это довольно нишевая область, потому что большинству веб-разработчиков никогда не доводилось писать собственный код обработки аудио в реальном времени или что-то подобное; наверно, этим и объясняется то, что этот баг не устраняли много лет. Тем не менее, при повседневной работе над различными проектами я стабильно сталкивался с ним.
Минимальную схему воссоздания бага создать было очень легко. Достаточно было создать AudioWorkletProcessor
при помощи скрипта с заданными заголовками кэша, обновить скрипт, а затем перезагрузить страницу с открытыми Devtools и включённой опцией Disable Cache.
Если бы всё работало правильно, то новый скрипт был бы загружен заново, а в противном случае сетевой запрос не выполнялся бы, и использовался бы устаревший скрипт.
Получаем код + собираем Chromium
Первый шаг к устранению бага заключается в сборке Chromium с нуля. К счастью, существует подробная документация по тому, как сделать это во всех популярных операционных системах.
Лично я работаю в Linux, так что всё было довольно просто. Пришлось установить несколько пакетов APT, а в остальном оказалось достаточно выполнить git clone
исходного кода и начать сборку при помощи ninja
.
Однако для сборки Chromium требуется очень мощная машина. У меня вполне приличный компьютер с CPU 7950X и M.2 SSD, но чистая сборка всё равно заняла примерно 45 минут. Кроме того, при компиляции на 32 потоках сборка использовала до 50 с лишним ГБ ОЗУ и более 100 ГБ на диске.
Впрочем, инкрементальные сборки довольно быстры, обычно они занимают меньше 10 секунд.
Несмотря на длительность компиляции, на самом деле после установки всего необходимого, сборка не вызывает никаких трудностей.
Я сгенерировал конфигурацию сборки, выполнил autoninja -C out/Default chrome
и примерно 45 минут спустя получил исполняемый файл в папке ./out/Default/chrome
, который запускался и идеально работал. Идеально, если забыть о том, что всё работало невероятно медленно, ведь это была неоптимизированная сборка со всеми включёнными отладочными проверками.
Ищем баг + пишем исправление
После того, как сборка заработала, можно было приступать к коду.
Я быстро осознал, что кодовая база Chromium ОЧЕНЬ велика.
На 2024 год в Chromium суммарно примерно 33 миллиона строк кода. Думаю, очень немногие обладают достаточным пониманием того, как связаны все его компоненты даже на высоком уровне, а ведь кодовая база постоянно меняется и растёт, иногда дополняемая десятками коммитов в час.
Из-за такого огромного размера кодовой базы мне не удалось заставить хорошо работать с проектом расширение C++ VS Code. Такие функции, как переход к определению (я активно её использую при перемещении по кодовым базам) и поиск ссылок, работали плохо или вообще не работали, а когда проект был открыт, одно из ядер моего CPU постоянно было загружено на 100%. Я уверен, что кто-то уже создал конфигурации или альтернативные расширения, чтобы всё это работало лучше, но лично мне этого сделать не удалось.
▍ Процесс отладки
Я начал отладку с поиска того, где инициируется сетевой запрос для скрипта ворклета, и выполнил трассировку до того места, где действительно выполняется запрос (или извлекается из кэша). Дерево вызовов выглядело примерно так:
Worklet::FetchAndInvokeScript
WorkletGlobalScope::FetchAndInvokeScript
DedicatedWorkerGlobalScope::FetchModuleScript
WorkerOrWorkletGlobalScope::FetchModuleScript
Modulator::FetchTree
ModuleTreeLinkerRegistry::Fetch
ModuleTreeLinker::FetchRoot
ModulatorImplBase::FetchSingle
ModuleMap::FetchSingleModuleScript
ModuleScriptLoader::Fetch
ModuleScriptLoader::FetchInternal
WorkletModuleScriptFetcher::Fetch
ScriptResource::Fetch
ResourceFetcher::RequestResource
Под этим деревом вызовов в отдельном сетевом потоке выполняется сам запрос, а ответ асинхронно отправляется вызывающей стороне.
Это довольно хорошо демонстрирует то, как структурирован код Chromium. Всё хорошо организовано и разделено, но присутствует много косвенности и разбиения на модули. Кроме того, везде активно используется динамическая диспетчеризация, что усложняет анализ того, какой код выполняется в конкретной точке вызова.
Без малейшего стыда признаюсь, что разбираясь в этих путях выполнения кода, намеренно выбрал отладку при помощи printf. Chromium достаточно неплохо справляется с поддержкой чистоты stdout во время обычной работы, так что мне было довольно просто отслеживать свои сообщения логов.
Моя стратегия заключалась в том, чтобы двигаясь вниз по дереву вызовов, выводить в лог всё хотя бы немного интересное. В процессе возникало множество ложных следов и тупиков, но в конечном итоге мне удалось найти что-то перспективное.
▍ Баг
Я заметил, что для каждого запроса задавался devtools_id_
; исключение представлял запрос, выполняемый ворклетом.
Кроме того, место, где это происходит, находится достаточно высоко в дереве вызовов, гораздо раньше начала любых взаимодействий с кэшем.
Я постараюсь не особо вдаваться в технические подробности происходящего в коде, и опишу всё вкратце.
Когда Devtools прикрепляются к адресату, они должны создать InspectorNetworkAgent
и прикрепить его к сессии.
После длительной отладки и изучения кода я понял, что InspectorNetworkAgent
не создавался для адресата-ворклета.
Спустя несколько часов я разобрался, почему.
Хотя сам адресат был ворклетом, для управления своей сессией Devtools он использует WorkerInspectorController
. Это вызвано тем, что AudioWorklet
работают вне основного потока в потоке воркера.
Виновниками были эти несколько строк:
if (auto* scope = DynamicTo<WorkerGlobalScope>(thread_->GlobalScope())) {
auto* network_agent = session->CreateAndAppend<InspectorNetworkAgent>(
inspected_frames_.Get(), scope, session->V8Session());
// ...
}
Хотя ворклет работал в потоке воркера, у него не было WorkerGlobalScope
, а только WorkletGlobalScope
. Из-за этого данное преобразование возвращало нулевой указатель, а InspectorNetworkAgent
не создавался.
▍ Исправление
Чтобы устранить проблему, я решил изменить InspectorNetworkAgent
так, чтобы он принимал не WorkerGlobalScope
, а WorkerOrWorkletGlobalScope
. Необходимые изменения были минимальны, ведь WorkerOrWorkletGlobalScope
— это базовый класс, общий и для WorkerGlobalScope
, и для WorkletGlobalScope
.
Затем я изменил код в WorkerInspectorController
так, чтобы он преобразовывал область видимости в WorkerOrWorkletGlobalScope
и инициализировал InspectorNetworkAgent
и для ворклетов, и для воркеров.
Вот и всё! После этого всё работало идеально… хотя нет, вру.
Я видел, что InspectorNetworkAgent
действительно создаётся, но он никогда не инициализировался, devtools_id_
не задавался при запросе, и во вкладке Network ничего не отображалось.
После ещё пары часов напряжённого изучения кода я зашёл в тупик на границе RPC между фронтендом Devtools и самим Chromium. RPC для инициализации сетевых функций Devtools для адресата-ворклета просто не выполнялась.
Потеряв надежду, я начал изучать код на TypeScript самого фронтенда Devtools. Там я обнаружил файл, сопоставлявший типы адресатов Devtools с набором включённых для них возможностей.
К своему облегчению и радости я заметил, что Capability.Networking
для Type.Worklet
подозрительно отсутствует.
Добавив эту возможность и дождавшись пересборки, я, наконец, увидел в сетевой вкладке Devtools пункт скрипта processor аудиоворклета. Опция Disable Cache работала без изъянов, а баг наконец-то был полностью устранён.
Тестирование + ревью кода
Подчистив все длинные логи отладки, которые я разбросал по всей кодовой базе, и финализировав diff, я начал разбираться в процессе отправки изменений на ревью.
На каком-то этапе я нашёл презентацию в Google Docs под названием Life of a Chromium Developer, которая очень доходчиво объясняла процесс ревью и слияния изменений. Хотя некоторые части презентации устарели, она стала моим основным руководством.
Я создал аккаунт на сайте Chromium Gerrit code review и подписал CLA, с чем не возникло проблем, ведь я контрибьютил как частное лицо, не связанное с какой-нибудь компанией или организацией. Но я тщательно проверил, чтобы адрес электронной почты, который я использовал с Git, был таким же, как у аккаунта в Gerrit.
▍ Создание CL
Начало создания PR, которые в Gerrit называются change list (списками изменений), или CL, выглядит так же, как в почти любом рабочем процессе Git. Мы создаём новую ветвь, вносим изменения, пишем сообщение коммита и выполняем коммит.
Дальше есть небольшие отличия.
У Chromium собственные скрипты и инструменты, называющиеся depot_tools
. Некоторые из них добавляют в git новую подкоманду git cl
, которая используется для управления созданием CL.
Я выполнил команду git cl upload
, проводящую предварительные проверки наподобие форматирования кода и линтинга, но она не позволила мне загрузить файлы, пока я не добавлю себя в файл AUTHORS
.
Разобравшись с этим, я добавил описание изменения (по умолчанию оно совпадает с сообщением коммита, что меня устраивало), ссылки на соответствующие баги из баг-трекера, а затем создал CL. При этом я получил ссылку напрямую на этот CL в веб-UI Gerrit, и остальной частью процесса управлял преимущественно из этого UI.
▍ Пишем тесты
В Chromium ты самостоятельно выбираешь ревьюеров своего CL. Я посмотрел blame основных файлов, которые я модифицировал для устранения бага, и нашёл людей, внёсших в последнее время существенный вклад в эти файлы. Я не знал точно, скольких ревьюеров нужно выбрать, поэтому остановился на троих.
Все эти люди были сотрудниками Google, они контрибьютили в Chromium в рамках своей работы. Я был посторонним и контрибьютил впервые, поэтому постарался относиться к ним и их времени максимально уважительно, не спамил их уведомлениями и вопросами, ответы на которые мог найти самостоятельно.
Однако я совершенно не понимал, как добавлять тесты для этого исправления.
Chromium — это профессионально разрабатываемое критически важное приложение, поэтому его разработчики подходят к тестированию очень серьёзно и для него уже написано поистине гигантское количество тестов. Их было так много, что я даже не знал, где искать место для добавления своего теста этой фичи.
Я опубликовал комментарий к этой issue, в котором попросил помощи или ссылок на тесты, которые можно использовать в качестве эталона, и спустя пару дней получил ответ (отправил CL я в пятницу, а ответ получил в понедельник). Наряду с некоторыми комментариями к моему diff, которые я быстро зарезолвил, мне отправили и ссылку на папку, содержащую десятки сквозных тестов JavaScript, проверявших различные части функциональности изучения поведения сети в Devtools.
Исследовав несколько тестов с названиями, подходящими к моей ситуации с ворклетами, я смог собрать собственный тест, подтверждавший правильность исправления. На самом деле, создать его оказалось не так сложно; другие тесты имели одинаковые паттерны, которые я мог скопировать, а также кучу полезных вспомогательных функций.
Написанный мной тест подготавливает сессию Devtools, получает файл JavaScript из аудиоворклета, а затем ожидает, пока бэкенд Devtools не отправит сообщение, говорящее о том, что запрос был перехвачен. Вот, как всё это выглядит вместе:
async function(/** @type {import('test_runner').TestRunner} */ testRunner) {
const {session, dp} = await testRunner.startBlank(
'Verifies that we can intercept the network request for the script loaded when adding a module to an audio worklet.');
await Promise.all([
dp.Target.setDiscoverTargets({discover: true}),
dp.Target.setAutoAttach({autoAttach: true, waitForDebuggerOnStart: true, flatten: true}),
]);
const swTargetPromises = [
dp.Target.onceTargetCreated(),
new Promise(resolve => {
dp.Target.onceAttachedToTarget(async event => {
const swdp = session.createChild(event.params.sessionId).protocol;
const networkEnableRes = await swdp.Network.enable();
swdp.Network.onRequestWillBeSent(e => {
if (e.params.request.url.endsWith('audio-worklet-processor.js')) {
resolve([networkEnableRes, `Network.requestWillBeSent: ${e.params.request.url}`]);
}
});
swdp.Runtime.runIfWaitingForDebugger();
})
}),
];
await session.evaluate(
`new AudioContext().audioWorklet.addModule('/inspector-protocol/network/resources/audio-worklet-processor.js')`);
const [_swTarget, [networkEnableRes, scriptFetched]] = await Promise.all(swTargetPromises);
testRunner.log(networkEnableRes);
testRunner.log(scriptFetched);
testRunner.log("OK");
testRunner.completeTest();
});
Также мы ещё немного переписывались с другим мейнтейнером Chromium, который попросил меня добавить ещё один тест, подтверждающий, что и функциональность disable cache тоже работает для воркеров.
Прежде чем CL оказался готов к принятию, я добавил этот тест и зарезолвил в коде ещё несколько пунктов обратной связи, после чего получил первый из двух голосов LGTM ревью кода. Они указывали на то, что я должен ожидать, пока первый ревьюер одобрит моё изменение.
После этого я довольно долго не слышал никаких новостей. Я не виню в этом мейнтейнеров Chromium, они очень заняты, а я не работаю в привычной им команде, регулярно занимающейся контрибьютингом. Мне так ничего и не написал первый ревьюер, зато я всё-таки получил второй голос LGTM и одобрение на принятие PR.
После одного финального прогона CI этот CL был принят, а код попал в основную ветвь.
▍ Второй CL
Но на этом я ещё не закончил. Мне нужно было создать ещё один CL в репозитории devtools_frontend
для того изменения в одной строке, чтобы добавить Capability.Network
в адресаты-ворклеты Devtools.
Я подготовил этот CL, после чего спросил, нужно ли мне ещё добавлять какие-то тесты, но, похоже, этого не потребовалось, потому что я быстро получил два голоса +1 ревью кода, и CI одобрили.
Однако я считаю, что репозиторий Chromium devtools_frontend
страдает от распространённой в разработке ПО проблемы: нестабильных тестов.
На странице CL есть специальное место, где показывается текущее состояние дерева. В случае, если CI по какой-то причине красная или нестабильная, в этом месте указывается причина, а автоматическое слияние блокируется:
К счастью, после выполнения rebase моего изменения и повторного выполнения CI все тесты выполнились успешно (после четырёх внутренних повторных попыток).
После открытия дерева мой CL был принят автоматически, а моё исправление наконец-то было полностью завершено.
Релиз
У Chromium есть несколько каналов релизов, каждый из которых имеет собственную скорость релизов. Существует веб-приложение Chromium Dash, отображающее хэши последних коммитов, включённых в каждую версию в каждом из каналов:
Я проверял на этом сайте, выпущено ли моё исправление.
Из этих каналов чаще всего обновляется Chrome Canary (два раза в день). Эти обновления полностью автоматические. В 8 и 16 часов по тихоокеанскому времени бот создаёт метку из текущей вершины дерева и использует её для сборки новой Canary-версии.
После принятия моего второго CL я подождал, пока Chrome Canary выпустит версию, включающую в себя оба моих коммита. На это потребовалось примерно 24 часа; похоже, есть какая-то задержка между коммитами в репозиторий devtools_frontend
и временем, когда на них указывает основное дерево.
Я скачал Chrome Canary себе на компьютер, запустил Devtools, открыл одно из моих веб-приложений, использующих AudioWorkletProcessor
, и увидел невероятно прекрасное зрелище: запрос успешно отображался в списке сетевых запросов.
Опция Disable Cache тоже работала идеально, как и ожидалось (но я всё равно вздохнул с облегчением).
В конце концов, баг был устранён, и эта сага приблизилась к своему завершению. Со дня, когда я впервые начал работать над устранением этого бага, до публикации исправления Chrome Canary прошло чуть больше месяца.
На момент написания статьи остаются ещё недели или месяцы до того, как исправление попадёт в канал стабильных релизов. Несколько дней назад был выпущен Chrome 128, а моё исправление будет включено в Chrome 130.
К счастью, в своей повседневной работе я пользуюсь Chrome Canary (по крайней мере, на ноутбуке; у Chrome Canary нет Linux-версии), так что я уже могу получать пользу от этого исправления.
Результаты + ретроспектива
Хотя это заняло приличное количество времени и усилий, я очень рад, что потратил их на устранение этого бага. Этот опыт сильно отличался от того типа разработки, которым я занимался в прошлом, и было здорово понаблюдать за тем, как создаётся ПО масштаба Chromium.
Одним из самых больших источников мотивации для меня стало то, что если я преуспею, то написанный мной код станет частью приложения, которое рано или поздно будет работать на миллионах (или миллиардах?) устройств.
Даже несмотря на то, что само изменение было нишевым и больше касалось инструментария разработчика, чем основного браузера, возможность сделать такой вклад очень меня привлекла.
Получив опыт контрибьютинга в Chromium, я буду искать новые баги, чтобы иметь возможность устранять их в будущем. Впрочем, не думаю, что для их поиска я буду сильно отклоняться от своего основного пути, потому что для изучения кодовой базы Chromium с нуля требуется слишком много труда.
Автор: ru_vds