Carakan теперь поддерживает самый современный стандарт JavaScript1! Как и предсказано, рассказа хватит на несколько серий.
Это была изматывающая работа, включающая в себя множество интересных решений, принятых по самым разным причинам. Что-то просто подходило движку, что-то пришлось взять как компромисс, а где-то и вовсе не существовало альтернативы.
Потребовался на это ровно месяц. Я понимаю, что в ситуации, когда почти весь труд берут на себя AI-агенты, сроки мало что значат, а код ничего не стоит, поэтому привожу этот срок для сравнения; обычно пара крупных фич и горстка мелких появлялись за неделю. А тут — месяц непрерывной работы агентского роя, закончившийся изменением 56 637 строк в самом движке и 5 518 216 строк вместе с тестами и документацией.
Тесты были самой болезненной частью, и мне не терпится поделиться этой болью. AI-агенты хороши, когда надо создать верифицируемый результат, а не какую-то абстрактную штуку. Имея на руках набор Test262, мы можем приблизиться к результату настолько, насколько возможно, но эти тесты надо прокручивать после каждого изменения, и прокручивать в шести разных конфигурациях (win x64 release, win x64 debug, win x86 release, win x86 debug, linux release, linux debug). Платформенный код может отличаться, поведение компиляторов может отличаться, что угодно может случиться, поэтому надо.
Больше всего проблем доставляли debug-сборки. Отладочный код добавляет существенный оверхед, и если релизные сборки прогоняли все 81 178 кейсов часа за три, то первый полный прогон debug-сборки занял больше тридцати часов. Конечно, тут была ошибка, и когда я разобрался с ней, время теста удалось сократить до каких-то жалких шести часов.
Тест находит проблему. Вы исправляете её. Ждёте шесть часов. В лучшем случае — двигаетесь дальше. В худшем — ваше исправление не сработало, или открыло новую проблему, или сработало, но сломало что-то в другом месте.
Даже при оптимальном воркфлоу, когда работа распараллелена на несколько машин, это всё равно крайне изматывает. Но вы понемногу движетесь к цели, исправляя одну проблему за другой, и вот, кажется, всё. Остался один, последний квалификационный прогон. Если он проходит — можно записать итог и радоваться!
Я оставил финальный прогон на ночь на личном ноутбуке, чтобы, проснувшись, первым делом посмотреть на результат. Но вместо цифр я увидел пустой рабочий стол.
Именно этой ночью Windows Update решил перезапустить машину. Я был в ярости — результаты прогона оказались неполными, и пришлось потратить ещё шесть часов.
Но теперь кошмар позади, и я могу рассказать о том, как это работает.
Расскажу, как вообще эти тесты устроены.
Test262 — общий набор проверок JavaScript, который разрабатывается под эгидой TC39, комитета по стандартизации языка. Число в названии отсылает к ECMA-262 — документу, описывающему, как должен работать JavaScript. В стандарте написаны правила; в Test262 собраны программы, позволяющие проверить их выполнение.
Каждая такая программа задаёт движку небольшой вопрос с заранее известным ответом. Например, метод at() умеет брать элементы массива с конца, если передать ему отрицательный индекс. Упрощённая проверка могла бы выглядеть так:
const numbers = [10, 20, 30];
assert.sameValue(numbers.at(-1), 30);
assert.sameValue(numbers.at(-4), undefined);
В первой строке проверки мы просим последний элемент, во второй — выходим за начало массива, где элемента уже нет. Функция assert.sameValue сравнивает полученное значение с ожидаемым и сообщает об ошибке, если они различаются. Время выполнения здесь не оценивается: хоть медленно, но движок должен ответить правильно.
На этом проверка метода, конечно, не заканчивается. Есть пустые массивы, пропущенные элементы, дробные индексы и аргументы совершенно неподходящего типа. JavaScript позволяет даже передать вместо индекса объект, который выполнит собственную функцию при превращении в число. Эта функция может изменить массив или выбросить исключение. Стандарт описывает и такие случаи, вплоть до порядка действий: от него зависит, что успеет произойти до ошибки. Так у одной с виду простой операции появляются десятки отдельных проверок.
Часть тестов специально содержит ошибочную программу. Чтобы пройти такой тест, движок должен выдать правильную ошибку в правильный момент. Если проверка ждёт ошибку при выполнении, а Carakan вообще не смог разобрать новый синтаксис, засчитать успех тоже нельзя.
Многие тесты запускаются дважды: в обычном режиме и с директивой "use strict", включающей более строгие правила JavaScript. Поведение в этих режимах местами различается, всё это нужно учитывать и отслеживать, чтобы понимать, в какой момент что-то пошло не так. В текущем наборе для ECMAScript 2026 присутствует 42 517 файлов, что в комбинациях режимов даёт 81 178 вариантов кейсов.
Этим же набором пользуются разработчики V8 из Chrome, SpiderMonkey из Firefox и JavaScriptCore из Safari. У каждого движка есть свои средства запуска Test262 и собственные дополнительные тесты. Общий набор особенно полезен при добавлении возможностей языка: одни и те же программы проверяют разные реализации, и ожидаемый ответ не зависит от того, кто написал движок.
Carakan может быть собран как в составе движка, так и в отдельную консольную программу jsshell — это сделано как раз для удобства и скорости тестирования. В jsshell можно просто передать JS-файл и получить ответ; это очень легко автоматизировать чем-нибудь вроде Python (как у нас и сделано).
Некоторым проверкам нужны возможности, которых у обычной JavaScript-программы нет. Например, попросить движок запустить сборщик мусора или создать отдельное окружение для выполнения кода. Test262 для этого описывает специальный служебный интерфейс $262. По сути, это программный интерфейс внешнего управления, который разработчики браузера должны добавить себе сами.
Есть свои приколы с асинхронными тестами. Основной код программы может выполниться до того, как обработчики Promise вернут результат. В Test262 есть способы проверки такого кода, но движок нужно научить не завершаться после выполнения основного скрипта и правильно об этом рапортовать. Здесь пришлось встроиться в цикл обработчика заданий Promise.
jsshell хватает для прогонов во время разработки, но итоговую квалификацию запускать всё равно нужно в полном окружении. Для этого написан небольшой сервер на Python, раздающий файлы и принимающий результаты. Браузер открывает управляющую страницу, которая по очереди запускает проверки в iframe. Каждый тест получает собственное окружение, чтобы тесты проходили изолированно.
Большая часть этой инфраструктуры была написана ещё при добавлении ES6, и с тех пор дорабатывалась с сохранением общей архитектуры. JS-скрипт, который управляет проверками, вообще написан ещё на ES5 — он обязан работать в любой редакции Carakan.
Исходные файлы Test262 хранятся отдельным репозиторием на зафиксированном коммите под текущий актуальный стандарт языка. Просто брать самую актуальную версию нельзя — в неё попадают и исправления старых тестов, и проверки ещё обсуждаемых возможностей языка, из-за этого наши результаты могут начать «дрифтить».
Мы же сохраняем себе результаты всех прогонов — на каком коммите какой тест прошёл или сломался. Это позволяет делать независимые проверки в разных окружениях, отслеживать и находить регрессии.
Эх, работало бы это ещё побыстрее…
Прогон всех тестов в однопоточном режиме (Carakan пока иначе и не умеет) без JIT может легко занять три часа и больше. Это не особенность Carakan: V8, JavaScriptCore и SpiderMonkey в тех же условиях будут работать так же долго и, возможно, даже медленнее. Но у них есть и JIT, и многопоток, поэтому они справляются с тестами за считанные минуты.
Отладочная сборка ожидаемо медленнее релизной из-за дополнительных обвязок вокруг кода. Но больше суток на один проход — это абсолютно ненормально. Пришлось погонять тесты под gdb, собрать стеки и посмотреть, что же там не так.
Конечно же, это был сборщик мусора.
Про концепцию GC можно писать отдельную книгу (которая, в общем, уже написана). Если совсем коротко: это механизм, который сам находит и уничтожает больше не нужные программе объекты, освобождая выделенную под них память. Плюс этого подхода в том, что эффективный GC освобождает программиста от необходимости отслеживать жизненный цикл каждого объекта. Минус… ну, в общем, во всём остальном. Нужно придумать, как со стопроцентной уверенностью автоматически отслеживать состояние объекта; если объект достижим в коде, удалять его нельзя.
Существуют языки программирования со сборщиком мусора, без него и такие, где он опционален. В JavaScript Garbage Collector есть.
После каждого теста мы убираем его временное окружение. Однако управляющая страница продолжает работать, и всё, что она хранит, тоже должно быть проверено сборщиком мусора. В нашем случае среди этих данных оказался огромный каталог самих тестов. Управляющий скрипт получал описания порциями, складывал их вместе, разворачивал в список вариантов запуска и держал всё это в памяти. Десятки тысяч записей — какие уже прошли, какие ещё предстоит выполнить. Перед началом каждого следующего теста браузер тратил ресурсы на обход этого хозяйства.
В Win32 Debug это обходилось особенно дорого. Даже короткая функция, читающая признак типа объекта, в такой сборке обрастает проверками компилятора и отладочной обвязкой. Добавьте сюда время на проверку GC, а затем суммируйте с временем проверки остального кода — вот и получите 30 часов.
После исправления управляющего скрипта всё заработало в разы быстрее — это коснулось как debug, так и production-сборок. Это всё ещё долго, но, по крайней мере, уже не тоскливо долго.
Сейчас Carakan проходит 81 176 тестов из 81 178 в наборе для ECMAScript 2026, во всех шести конфигурациях с абсолютным совпадением результата. Две оставшиеся проверки — это один тест в sloppy- и strict-режимах, проверяющий Function.prototype.toString() для устаревшего свойства RegExp.$&; его необычное имя при преобразовании в строку функции-геттера превращается в get $&. Это известная несогласованность проверки и описания устаревшего расширения; само расширение в стандарт ECMAScript 2026 не входит.
Для расширения Intl проходит 2 450 из 2 486 проверок. В 34 случаях тесты запрашивают локали, которых нет в нашем пакете ICU. Ещё два случая упираются в пробел перед AM при выводе времени. Тест ждёт обычный пробел, а используемая нами версия международной базы данных CLDR предписывает узкий неразрывный.
Теперь вы понимаете, с какой дотошностью всё это проверяется.
Но такая полнота достигается только в консольном jsshell. В полноценной сборке специально отключены 980 кейсов SharedArrayBuffer: разделяемая память уже реализована в Carakan, но всё ещё оставлена недоступной обычным веб-страницам. Чтобы это заработало безопасно, нужно дорабатывать изоляцию документов и работу веб-воркеров.
Ну и, пожалуй, главное. Прохождение Test262 — это очень, нереально, невозможно, сверхъестественно круто! По данным test262.fyi примерно на том же уровне его проходит только восемь движков (скорее всего, реальное количество будет немного больше, т.к. в статистику входят не все любительские движки).
К сожалению, это всё ещё не гарантирует, что JavaScript на всех сайтах будет работать. Test262 не покрывает всю спецификацию языка, и именно поэтому основные браузеры имеют свои наборы (которые мы, конечно же, попробуем адаптировать). Где-то что-то не работает, потому что ожидаемых API всё ещё нет в самом браузере, а где-то… ну, пока даже непонятно, почему. Но это уже можно отслеживать и разбираться точечно.
Я доволен!
Спецификация ES2020 ввела в JavaScript новый базовый числовой тип данных — BigInt.
Я уже немного рассказывал, как Carakan хранит значения. Его универсальная ячейка занимает восемь байт в 32-битной сборке и шестнадцать — в 64-битной, она может хранить данные и метку типа. Число или логическое значение можно записать прямо в ячейку; для строки или объекта хранится ссылка на отдельно выделенную память.
На 32 битах упаковка особенно плотная: для меток используются некоторые битовые комбинации NaN, специального значения «не число». Такой способ называется NaN-boxing. Подробности упаковки мы уже разбирали; здесь важно, что размер ячейки фиксирован, а место под служебные признаки давно распределено. Так что надо не только реализовать сам тип данных, но ещё и придумать, как уместить его в текущей архитектуре.
Вообще, компьютеры вовсе не так уж и хороши в работе с числами, как кажется. Считать-то они умеют хорошо, а вот представлять числа — не очень. Тут, опять же, можно углубляться в асфальт и земное ядро, рассказывая о двоичной записи дробей, отрицательных нулях, положительных бесконечностях и прочих радостях жизни.
Пока взглянем только на парочку из них.
Для хранения обычного числа нужной разрядности можно выделить фиксированное количество бит. В эти биты можно записать само число, но так мы быстро упрёмся в ограничение диапазона. Поэтому для работы с очень большими величинами и дробями часто используют числа с плавающей точкой, отдельно сохраняя знак, значащие цифры и порядок (примерно как запись ± 1,2345 × 10²⁰). Места под значащие цифры всё равно немного, поэтому при необходимости число округляется. Правила того, как подобные числа должны представляться и вычисляться, стандартизированы спецификациями IEEE.
В JavaScript до BigInt был один числовой тип — Number, с правилами вычислений для 64-битного формата IEEE 754. Он использует такую запись, только двоичную. На точность приходится 53 двоичных разряда — примерно шестнадцать десятичных цифр.
Все целые от −(2⁵³ − 1) до 2⁵³ − 1 можно безопасно различать и хранить без потери точности. Дальше точность уже не позволяет представить каждое соседнее целое: сначала между доступными значениями появляется шаг в две единицы, потом в четыре, и так далее. Поэтому в JS возникают подобные парадоксы:
9007199254740992 + 1 === 9007199254740992 // true
Другая хохма: десятичная 0.1 не имеет конечной записи в двоичной системе, примерно как 1/3 в десятичной. Поэтому 0.1 + 0.2 в итоге даёт 0.30000000000000004 — и приходится выдумывать ухищрения, казалось бы, на ровном месте.
Новый тип BigInt призван решить первую проблему. Он должен хранить целое число с переменным количеством разрядов. Если результат становится длиннее, под него выделяется больше памяти и точность никогда не теряется:
9007199254740992n + 1n // 9007199254740993n
В исходном коде BigInt задаётся суффиксом n или приведением через функцию BigInt(). О других его особенностях и ограничениях можно почитать в блоге V8, нам же для понимания хватит и этого.
Вернёмся к ячейкам типов данных.
Само длинное число придётся вынести в отдельный блок памяти. Но в ячейке ещё нужна метка, по которой Carakan узнает BigInt и выберет соответствующую арифметику. Таблица существующих меток тесно связана с проверками типов и генерацией машинного кода; расширение затронуло бы самые часто используемые части движка.
Со сборщиком мусора возникла похожая проблема. Он тоже различает типы выделенных объектов по меткам в заголовке. Под такую метку отведено шесть бит — 64 возможных значения. Новый тип засунуть просто некуда.
Долго гадать не пришлось: такая же задача уже решалась для Symbol. В ячейке есть метка для ссылки на внутренний объект движка, подробности которого можно узнать по его заголовку. BigInt получил такую ссылку и дополнительный признак в заголовке, отличающий его от остальных объектов этого вида. Размер универсальной ячейки и заголовка GC остался прежним.
Само число хранится по частям, каждая занимает 32 бита. Складывать такие числа можно столбиком, только вместо привычных цифр от нуля до девяти каждая часть содержит значение от нуля до 2³² − 1. При переполнении переносим единицу в следующую часть. Знак хранится отдельно, части числа идут подряд за заголовком, в одном блоке памяти. Когда число становится недостижимым, GC может освободить весь блок целиком.
(Я представляю вопрос: а если вдруг захочется портировать Carakan на процессоры с разрядностью меньше 32 бит? Ну тогда, скажу я вам, BigInt будет далеко не на первом месте в списке проблем.)
Арифметику можно было взять из какой-нибудь библиотеки, правда, пришлось бы согласовывать выделение памяти со сборщиком мусора и принятой в Opera обработкой нехватки памяти. Поэтому сначала был написан свой прототип, постепенно обученный сложению с переносом, умножению столбиком, делению по разрядам и прочей начальной школе. Результаты сверялись по Python, в котором такая арифметика тоже есть. Понемногу реализация усложнялась, дорабатывалась — и так и осталась.
Технический лимит значения для BigInt в текущей реализации — 2⁶⁵⁵³⁶ − 1, без малого двадцать тысяч десятичных цифр, что значительно больше, чем количество атомов в наблюдаемой Вселенной. С увеличением длины числа растёт и объём вычислений, особенно при умножении и делении, так что тут разумно было бы остановиться.
Попробуйте вывести сообщение о количестве найденных файлов. «Найден 1 файл», «найдено 2 файла», «найдено 5 файлов». На двадцати одном снова будет «файл», хотя число явно больше единицы. Если вы когда-нибудь писали такую функцию для русского языка, то простите за неприятное напоминание.
С датами и числами похожая история. Запись 03/04/2026 в США и Великобритании прочитают как разные даты, а в России за такую запись даты вас скормят медведю. В числе 1,234 запятая может отделять дробную часть или тысячи. Даже привычная сортировка по алфавиту зависит от языка; например, в шведском Ö идёт после Z, а в немецком — рядом с O. А ещё есть сегментация текста (не во всех письменностях есть пробелы), нормализация Unicode (ä ⇔ a+◌̈), транслитерация…
Набор языковых и региональных настроек называют локалью: en-US — английский для США, en-GB — для Великобритании. Программе может понадобиться показать одну и ту же сумму по правилам разных стран, независимо от языка установленной операционной системы.
В JavaScript для этого существует Intl, описанный отдельным стандартом ECMA-402. Например, он умеет выбирать форму множественного числа:
const rules = new Intl.PluralRules("ru");
rules.select(21); // "one"
rules.select(22); // "few"
rules.select(25); // "many"
Узнав от Intl категорию, программа может выбрать нужную форму слова. Остались пустяки — научить этому само Intl.
Все описанные проблемы не новы, и решать их пробовали многие. Свои подходы были в разных языках и операционных системах… но, честно говоря, проблема настолько общая, что против одного стандартизированного решения, кажется, никто не возражал.
Сейчас таким стандартом де-факто является библиотека ICU, поддерживаемая консорциумом Unicode. В ней есть алгоритмы работы с текстом, форматирование чисел и дат, календари, правила сравнения строк и всё остальное, что требуется для локализации и интернационализации. Это один из столпов, на которых держится почти вся цифровая инфраструктура, и именно через ICU реализован Intl в Chrome, Safari, Firefox и, наверное, ещё в 99% программ, которым нужна работа с локалями.
Однако «стандартный» — не значит «идеальный». ICU монолитен и тяжеловесен, поэтому под эгидой того же консорциума разрабатывается альтернатива ICU4X, написанная на Rust. Выбирая между ними, я всё-таки остановился на более старом и проверенном варианте: опыт интеграции сторонних C++-библиотек у меня уже есть, а добавление обвязок вокруг Rust не проходило через бритву Оккама.
Некоторая часть языковых сведений приходит из CLDR — сопровождаемой базы правил для языков и регионов. Она содержит названия месяцев и валют, шаблоны дат, правила множественного числа и многое другое.
Хотя прежде Opera тоже работала с локалями внутри своего UI, ей хватало возможностей, предоставляемых операционными системами. Но для постройки Intl этого бы не хватило, не говоря уж о том, что разные ОС дают разную поддержку.
Так что под Intl у нас подложен ICU 78.3 (взят сокращённый пакет на 36 локалей) + CLDR 48.2 + таблицы Unicode 17.0.
Объект Intl не то чтобы тривиален, но его основная сложность перекладывается на ICU. Эту библиотеку нужно сначала подключить к Carakan, для чего у неё предусмотрен канонический путь: ICU собирается отдельно, предоставляя обычный C-интерфейс, а Opera дёргает эти функции.
Альтернативный вариант создания собственного форка ICU4C с переписыванием под оперовские парадигмы мы рассматривать не будем, потому что ложиться в дурку нам пока рано.
Вот как эта интеграция работает на примере Intl.DateTimeFormat:
Когда JavaScript создаёт Intl.DateTimeFormat, Carakan разбирает параметры и сохраняет выбранную локаль, календарь, часовой пояс и шаблон даты. Эти строки и настройки находятся в памяти, которую обслуживает GC.
При форматировании адаптер создаёт временный объект ICU, получает результат в подготовленный буфер и закрывает объект через функцию самой библиотеки. После возврата Carakan превращает результат в JavaScript-строку. Постоянного указателя на форматтер ICU внутри JS-объекта нет. Ошибки тоже проходят через освобождение временных ресурсов: сначала заканчивается вызов библиотеки, затем движок сообщает о неудаче.
Аллокаторы ICU подключены к Opera через штатный механизм u_setMemoryFunctions. Библиотечная память при таком выделении не становится частью JavaScript-кучи, и сборщик мусора ею не занимается. Это позволяет нам контролировать выделения памяти ICU и намеренно вызывать отказы при тестировании (что, как вы сейчас убедитесь, пригодилось).
В условиях нехватки памяти библиотека ICU иногда падала раньше, чем успевала сообщить об ошибке. В Opera, напомню, OOM — не катастрофа, функция должна сообщить об этом, а вызывающий код обработать, и в этом суть TRAP/LEAVE.
Выпрыгнуть из глубины ICU прямо к обработчику Carakan нельзя: мы пропустим код, который должен освободить промежуточные объекты библиотеки. Это похоже на проблему переключений в OpPseudoThread, про которую я рассказывал на прошлой неделе.
Но там мы переключались из нашего кода обратно в наш и могли просто доработать логику переключений. Переписывать внутреннюю обработку нехватки памяти в ICU не кажется мне хорошей идеей.
Решение оказалось парадоксальным. Перед вызовом ICU готовится аварийный резерв памяти, и если обычное выделение во время вызова не удаётся, адаптер использует резерв, запоминая факт отказа. Библиотека отрабатывает на этом резерве без падений, адаптер дожидается результата, но никак его не использует, просто сообщая Carakan о нехватке памяти. Само собой, если зарезервировать память не удаётся — это мгновенный OOM, до ICU дело даже не доходит.
Другой интересный кейс: форматирование даты падало по переполнению стека на шести вложенных вызовах, но начинало работать, если вложенность вырастала до девяти. Не очень логично.
Всё встаёт на свои места, если вспомнить, как устроен стек (а точнее, стеки) в Carakan. Аллокатор выделяет псевдопотоку OpPseudoThread 16 КиБ (это значение досталось нам по наследству, и оно выглядело вполне разумным в парадигме экономии), а когда эта память заканчивается, аллокатор должен добавить новый сегмент. Проверка простая: осталось меньше 3 КиБ — получи ещё памяти.
При этом предсказать заранее, сколько памяти потребуется, нельзя (ну, или можно, но оно того не стоит). Полагаемся на эмпирические наблюдения и здравый смысл.
При добавлении Intl не было проверено, сколько стека захотят новые функции. Сами функции умещались в стек с запасом немногим больше 3 КиБ, но первый же вызов форматтера требовал 3696 байт и вываливался за границу стека. А если вызовов было девять и больше, в запасе оставалось меньше 3 КиБ, и аллокатор добавлял ещё памяти сразу же.
Пришлось отдельно добавить проверку: если вызывается Intl, аллокатор проверяет, есть ли у псевдопотока хотя бы 64 КиБ — по замерам, этого хватает с пятикратным запасом даже для самого «жирного» вызова. Решение временное, но пока что достаточное.
На следующей неделе попробую рассказать про новый парсер RegExp, реализацию WeakRef и FinalizationRegistry, разделяемую память через SharedArrayBuffer и Atomics…
Продолжение следует.