Охота на JavaScript Heisenbug

Есть много разных ошибок. Вот самые простые: вы видите сообщение об ошибке, снимаете лицо ладонью и исправляете его. Есть самые сложные. Различные компоненты сложного программного обеспечения взаимодействуют друг с другом непредвиденным образом, и требуется хорошая отладка.

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

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

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

function toInt(value, radix) {
  return value / radix | 0;
}

Что может пойти не так? Мы должны начать с самого начала, чтобы понять, как мне потребовалось три дня на отладку, сборку V8 из исходников и почему это настоящий Heisenbug.

Откуда берутся эти исключения?

Мы создаем компилятор с интерфейсом байт-кода Java и различными бэкэндами, одним из которых является JavaScript. Наш компилятор ленив так же, как виртуальная машина Java оценивает и проверяет байт-код. У вас может быть метод, ссылающийся на класс, которого нет во время выполнения. Если этот метод никогда не вызывается, вы никогда не получите ClassNotFoundException. То же самое и с нашим компилятором. Можно сказать, что ее различные фазы, абстрактная интерпретация и независимая от платформы система типов уже довольно сложны. Поэтому я всегда сначала ищу ошибку в нашей системе. Веб-серверная часть содержит более 35 фаз компилятора, не считая конвейера оптимизатора. Множество вещей, которые могут пойти не так.

Недопустимое целое число: «15».

Однажды мне представили исключение NumberFormatException внутри приложения, которое в остальном работало нормально. Сообщение было странным. Он сказал, что 15 - недопустимое целое число. Я перезагрузил приложение, и оно сработало нормально. Не о чем беспокоиться. Может быть, я где-то ввел старую ошибку, и файл JavaScript был кеширован. Я использую Ubuntu и канал разработчиков Chrome. Я привык к странному поведению, и в тот день у меня на тарелке были другие дела.

Ошибка исчезла, и я больше с ней не сталкивался. Некоторое время.

Быстрее быстрее

Сборка компилятора означает, что вы готовы к долгому поиску. Конечная цель: максимальная производительность. Наш бэкэнд JavaScript делает компромисс между размером кода и производительностью. Вы можете уменьшить занимаемую площадь, если согласитесь с определенным снижением производительности. Мы пробовали это, например, с такими ключевыми словами, как null, true и false. Привязка их к глобальным односимвольным переменным уменьшает размер кода, но дает снижение производительности на 20% в тестах.

Переименование оказывает наибольшее влияние на размер кода. Проще говоря, каждое имя в классе Java должно быть полностью определено для разрешения в JavaScript. Вот пример:

class Foo {
  public int value;
}
class Bar extends Foo {
  public int value;
}
void set(Bar bar) {
  bar.value = 123;
}
int get(Foo foo) {
  return foo.value;
}
void main(String[] args) {
  Bar bar = new Bar();
  set(bar);
  System.out.println(get(bar));
}

Что печатается? Результатом будет 0, поскольку Foo :: value не является Bar :: value. Как мы моделируем это поведение при отправке JavaScript? Мы не можем просто сгенерировать this.value, поскольку оно неоднозначно. Одна из наших последних фаз разрешает все имена, чтобы они стали уникальными. Фактически, мы, вероятно, исполнили бы код вроде «bar.Bar_value = 123» и «return foo.Foo_value» для функции получения и установки.

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

Первый минификатор, который мы написали, работал по простому алгоритму. Используйте любой допустимый начальный символ идентификатора и продолжайте использовать все возможные символы части идентификатора. Первое имя, с которым мы столкнемся, станет «a», следующим «b» и так далее, пока мы не дойдем до «Z». Следующее имя будет «аа», «аб» и так далее. В чем проблема этого алгоритма?

Прежде всего, мы могли бы подсчитать количество вхождений имени во всей программе и назначить ему кратчайшую последовательность в качестве эвристики. Но что еще хуже, что, если мы сгенерируем такие имена, как «abs», «sqrt» или «sin»? Программы достаточно велики, поэтому мы создаем имена, которые начинают конфликтовать с API JavaScript. defrac позволяет разработчикам расширять собственные классы пользовательскими функциями и свойствами. В этом случае мы могли бы создать конфликт. Первым решением было выбрать префикс, который не используется в API, например _ или $. Теперь все наши переменные называются «_a» и так далее. Мне это не нравилось по разным причинам. Другие платформы JavaScript могут определять свои собственные свойства, разработчики JavaScript могут определять свои собственные свойства, и в результате весь уровень взаимодействия становится нестабильным. Также, если мы начнем использовать дополнительный байт, нет ли лучшего решения?

Юникод. (Вау, я не мог поверить, что когда-нибудь напишу это)

Идентификатор должен начинаться с $, _ или любого символа в категориях Unicode « Заглавная буква (Lu) , «Строчная буква (Ll) », «Заголовочная буква (Lt) », «Буква-модификатор. (Lm) », « Другая буква (Lo) » или « Буквенное число (Nl) ».

Остальная часть строки может содержать те же символы, а также любые символы, не являющиеся соединителями нулевой ширины U + 200C, объединяющие символы нулевой ширины U + 200D и символы в категориях Unicode « Знак без пробелов (Mn) », «Метка объединения интервалов (Mc) », «Десятичное число (Nd) »или «Пунктуация соединителя (Pc) ».

Я начал генерировать символы Unicode для наших идентификаторов. Это всего лишь один символ, но все же два (или более!) Байта - без уменьшения размера файла.

С другой стороны, V8 - очень сложная программа со всевозможными эвристиками и уловками, заставляющими код JavaScript летать. Один из них - это ленивая компиляция. И одна эвристика при встраивании метода основана на количестве содержащихся в нем символов. Почему персонажи? Сначала звучит глупо. Но если вы никогда не генерировали AST, что еще вы могли бы использовать в качестве эвристики?
В терминах V8 «_a» - это два символа, тогда как «ツ» - только один символ. Довольно круто, да? Из-за нашего изменения в минификаторе мы можем обмануть V8, чтобы он оптимизировал больше кода. И именно тогда начали появляться исключения. Только я не знал. А я тогда не заметил.

«Тобиас больше не может запускать Audiotool!»

Audiotool перестраивает свое приложение с помощью defrac. Однажды утром ко мне пришел Андре Мишель и сказал, что кто-то из офиса больше не может запускать Audiotool. На Mac. С бета-версией Chrome.

Я заинтересовался и пошел к Тобиасу, чтобы спросить, что случилось. Вот оно что.

Недопустимое int: «251»

Я сразу вспомнил сообщение примерно месяца назад. Но на этот раз это была бета-версия Chrome в нормальной операционной системе. Я попытался воспроизвести его в своей системе и загрузил приложение HTML5. Он представил мне «Illegal int: 16». Я перезагрузил «Illegal int: 251», снова «Illegal int: 134». Почему эти числа такие случайные? Обычно я ожидаю, что это не удастся с тем же сообщением, и кроме того: 16, 251 и 134 не являются недопустимыми целыми числами!

Обратное распространение

Искусственные нейронные сети обучаются с помощью метода, называемого обратным распространением. Вы представляете им желаемый результат для данного входа и распределяете веса синапсов назад по слоям сети. Мне нравится думать о своем способе отслеживания ошибок таким же образом. Audiotool - это большая часть программного обеспечения с более чем 500kloc на клиенте. Размер создаваемого файла JavaScript составляет прибл. 2,5 МБ, уменьшено. Очевидно, первая идея - начать с сгенерированного кода, в котором возникает исключение, назад к исходному коду, вызывающему проблему.

Это уже было пару раз. Мы не соблюдали семантику JVM в первые дни и наблюдали некоторые сбои при рендеринге. Причина заключалась в том, что какой-то совершенно не связанный код генерировал недопустимое значение, которое распространялось в систему рендеринга. Эти ошибки нелегко найти. Трассировка стека на самом деле не помогает.

Итак, я начал рассматривать реализацию Integer.parseInt; это тот, который бросает. Ничего подозрительного. Я создал небольшой пример приложения и попытался разобрать несколько целых чисел. Это сработало. Так что, если этот код правильный, что-то еще должно быть сломано. Как я уже сказал, Audiotool довольно большой, а минимизированный код содержит всевозможные бессмысленные символы, без пробелов и разрывов строк. Я клонировал репо и создал отладочную сборку с помощью defrac. Стремясь понять, что происходит, я запустил приложение, но больше не получал исключения.

В поисках виновника

Теперь есть очевидная разница. «Web: config debug true», и исключение исчезнет. «Web: config debug false», и он появляется снова. Каждый раз с другим значением, которое на самом деле является действительным целым числом.

Давайте выполним поиск использования параметра отладки в компиляторе.

  • Отключить разрывы строк и пробелы, если отладка ложна
  • Переименовать символы (переменные, классы,…), когда отладка ложна
  • Информация о типе вывода в комментариях, если отладка верна
  • Platform.debug () возвращает это значение во время выполнения

Конечно, переименование символов было самым многообещающим, поскольку я изменил его всего пару недель назад. Я отменил сдачу и вуаля! Без исключения.

Я думал, что создал неправильные имена, несовместимые с ECMA. Обсудив код, я не смог понять, что было не так, и eval () дал все возможные имена. Нет проблем. Если имена правильные, не что-то еще вызывает проблему? Может где-то не хватает места? Когда отладка ложна, мы генерируем только те символы пробела, которые абсолютно необходимы или позволяем нам торговать двумя символами «()». «X instanceof y» - такой случай. Я снова включил имена Unicode, выделил все разрывы строк и пробелы. Без исключения. Отсутствие ненужных пробелов ничего не изменило. Что осталось? Это должно быть связано с переносом строки. Вау!

Я начал искать случаи, когда мы ставим точки с запятой. Как перенос строки может повлиять на код? Когда точки с запятой опускаются. По крайней мере, это была моя теория. Я убедился, что все точки с запятой выставлены, и отключил разрывы строк. Я получаю исключение. Независимо от того, что я делаю с точкой с запятой, я получаю это исключение.

Я дошел до того момента, когда рассуждения о коде меня ни к чему не приводят. Я открыл инструменты разработчика в Chrome и попробовал разные конфигурации. Даже без разрывов строк и имен в юникоде. Без исключения, нада.

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

На следующий день. Новое начало. Если параметр отладки не действует при подключении отладчика, должно быть что-то еще не так. Обратное распространение.

  1. Выдается исключение из-за недопустимого целого числа
  2. Целое число анализируется
  3. Модель аудиоинструмента построена
  4. Формат JSON анализируется
  5. Сетевой ответ получен и проанализирован
  6. Сделан сетевой запрос для загрузки текущего сеанса
  7. Аудиоинструмент запускается

Я попытался взглянуть на все высокоуровневые компоненты, предоставляемые defrac. Исключение произошло и в моем коде около месяца назад. Поэтому я должен рассмотреть шаги 1, 2, 4, 5 и 6. В такой ситуации я пытаюсь изолировать компоненты и тестировать их по отдельности. К счастью для меня, сначала проще всего было протестировать целочисленный анализ.

Я реализовал целочисленный синтаксический анализ в JavaScript, и именно здесь возникает исключение:

while(offset < length) {
  var digit = todigit(str.charCodeAt(offset),radix);
  offset = offset+1|0;
  if(digit == -1) throw invalidInt(str);
  if(max > result) throw invalidInt(str);
  var next = Math.imul(result,radix)-digit|0;
  if(next > result) throw invalidInt(str);
  result = next;
}

Это не имеет отношения к проблеме. Но найти его было крайне важно. Запуск моего тестового парсера в цикле выявлял сообщение «Invalid int: 3678» каждый раз примерно после 5000 итераций. Потом меня осенило. Мне не удалось воспроизвести проблему с подключенным отладчиком, исключение происходит «случайным образом», а в отдельных случаях происходит сбой примерно с таким же количеством итераций.

Это чертова JIT!

Как работает виртуальная машина

Представьте себе виртуальную машину как оркестр. Каждый музыкант, высококлассный, играет свою роль. Если все сделают свою работу правильно, мы услышим красивую симфонию. Сборщик мусора, своевременный компилятор, среда выполнения и так далее. Нелегко управлять группой опытных музыкантов, и нелегко создать виртуальную машину, которая компилирует код до мелочей, но при этом может его отлаживать. Flash Player был примером обратного. Было два разных бинарных файла. Один с включенной отладкой (Debug Player) и один с отключенной отладкой (Release Player). Программа Debug Player запускала код настолько медленно, что отладка приложений была неинтересной. Почему? Потому что он всегда действовал так, как будто все методы активно отлаживаются. Оркестр перешел со 110 до 60 ударов в минуту, так что вы можете слышать каждую ноту индивидуально. Просто потому, что есть этот аккорд, которого ты не понимаешь.

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

Поскольку сложная оптимизация обходится дорого, виртуальные машины пытаются идентифицировать часто используемый код, также называемый горячим кодом. Отсюда и название HotSpot. Когда виртуальная машина обнаруживает такую ​​горячую точку, она стреляет изо всех сил, чтобы этот метод работал очень быстро. Подумайте об этом цикле:

var x = 0
while(x < 10000) {
 doSomething()
 x++
}

Виртуальная машина выполнит немного другую версию. Вот какой-то псевдокод:

var x = 0.0
var loop_iterations = 0
while(true) {
  var condition = vm_runtime::toDouble(x) < 10000.0
  if(!condition) {
    break
  }
   if(++loop_iterations > LOOP_OSR_THRESHOLD) {
    // loop is a hot-spot, optimize!
    vm_runtime::performOSR()
  }
  doSomething();
  x = vm_runtime::toDouble(x) + 1.0;
}

Когда вызывается vm_runtime :: performOSR (), этот цикл может быть заменен более оптимизированной версией, которая могла бы избавиться от двойных проверок, поскольку JIT может доказать, что x всегда является целым числом меньше 10000 - что-то V8 сделает.

Конечно, виртуальная машина также будет отслеживать, как часто вызывается doSomething (). Если он достигает определенного порога, он считается горячей точкой и также является достойным кандидатом для дорогостоящей оптимизации.

Чтобы подтвердить свою теорию, мне просто нужно было проверить, когда функция выполняется и оптимизируется ли она с помощью V8. Если после оптимизации кода возникает исключение, я знаю, что могу отправить отчет об ошибке.

Конец строки

У меня уже были последние исходники V8 на моем компьютере с тех пор, как я запустил наши тесты с использованием D8. Это простое приложение оболочки, которое выполняет JavaScript в REPL. Он отлично подходит для отладки проблем с производительностью, модульного тестирования, и я часто использую его для быстрого тестирования некоторых JavaScript. У D8 также есть отличный вариант:

--trace_codegen

Когда вы запустите образец кода с этой опцией, вы получите следующий результат:

3856
3855
3854
3853
[generating full code for user-defined function: ]
[generating optimized code for user-defined function: ]
[generating full code for user-defined function: ScriptNameOrSourceURL]
[generating optimized code for user-defined function: ToBooleanStub]
[generating full code for user-defined function: GetLineNumber]
[generating full code for user-defined function: ScriptLocationFromPosition]
[generating full code for user-defined function: ScriptLineFromPosition]
[generating full code for user-defined function: charAt]
[generating full code for user-defined function: SourceLocation]
/home/joa/Desktop/bug.js:2: Invalid int: 3852

Какое облегчение. Я подал рапорт, и его починили в кратчайшие сроки. Фактически вы можете видеть, что регрессионный тест намного проще. И это довольно страшно.

Последствия

Мог ли я заметить это раньше? Я так думаю. Моя первая ошибка заключалась в том, что я игнорировал первый раз, когда было создано исключение. Тогда найти его было бы намного проще.

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

Мне также интересно, стоило ли мне раньше начинать тестирование кода в другом браузере. Действительно, в Firefox это сработало. Но иногда это ничего не значит. Chrome и Firefox не синхронизированы с точки зрения API. И с учетом огромного количества выполняемого кода JavaScript обычно можно предположить, что виртуальная машина не виновата. Это усложняет отладку, потому что вы действительно не знаете, где искать, и при поиске ошибки лучше всего начать с себя.

Спасибо @mraleph за вычитку статьи. Загляните в его блог, если вас интересует актуальное внутреннее устройство V8 / dart.