Я почитал старые чаты и ветки форумов времён утечки исходников. Многие славные люди тогда поражались качеству кода, пытались его доработать, но редко забирались дальше запуска сборки. Любое серьёзное изменение требовало команды специалистов высокой квалификации, готовых посвящать проекту своё свободное время.
Теперь AI-агент за час делает то, что команда сделала бы за месяц. То, что в 2017 году выглядело невозможным, в 2026 году стало вопросом стодолларовой подписки.
Да, буквально как в меме: Claude Opus/Fable и GPT Sol работают вместе. Одна модель пишет реализацию, вторая строго её ревьювит — а я с умным лицом слежу за ними. Вся работа документируется в приватном репозитории. Список задач растёт по мере углубления в код пугающе быстро, но после работы с банковским легаси я уже ничего не боюсь.
Это отчёт о второй неделе работы над движком.
Я не стану рассказывать о мелких проблемах. Они могут быть примечательны сами по себе, но вряд ли вас заинтересует рассказ о том, как сборка ресурсов была переписана с Perl на Python во славу Оккама, и всё работало до того момента, как я развернул репозиторий на втором компьютере, и потратил день на то, чтобы с этим разобраться, а потом всё равно откатил патч. Я избавлю вас от такой рутины, но помните, что она есть, и её очень много.
Чтобы понять, куда идти, мне нужно было сначала понять, где мы находимся.
По старой памяти я открыл html5test.com. В бенчмарке, замороженном 10 лет назад, Opera 12.15 набрала 308 из 555 баллов (актуальные на момент написания статьи Firefox и Edge набрали 509 и 523 соответственно). Технически это не совсем тесты: страница просто запрашивает у браузера флаги поддержки, а сама реализация не проверяется. Однако это первый ориентир, посмотреть на который можно, просто открыв сайт в браузере.
Второй — тестовая сюита test262, проверяющая поддержку ECMA-262 JavaScript-движками. Чтобы её запустить, пришлось оживить jsshell — консольную сборку Carakan, умеющую гонять JS без браузера. Результат — 99,97% на каноническом наборе ES5.1 (актуальном в 2012 году), после нескольких точечных правок — 100% (11 572 из 11 572).
Современный test262 целиком содержит 92 886 тестов, из которых движок проходит 30 282. Увы, это не значит, что современный JS работает хотя бы на треть — он не работает вообще.
И третий, самый крупный ориентир: WPT.
Web Platform Tests — то, чем сегодня оценивают браузеры. Часть из них — testharness.js-тесты, которые надо просто исполнить внутри браузера; часть — рефтесты, где надо сравнить картинку с эталонной. Для этого нужно от браузера эту картинку получить.
Как заставить Presto отрисовать страницу и отдать PNG наружу? У движка нет headless-режима, нет CLI-скриншотилки, ничего такого. Зато у него есть Scope — протокол удалённой отладки, тот самый, поверх которого работала Opera Dragonfly: нативный protobuf по собственному транспорту STP. Он был сделан для того, чтобы вы могли отлаживать страницу, открытую на телефоне, сидя за десктопом.
Теперь по нему ходит CI. Тестовый раннер поднимает Оперу, подключается к её отладочному порту, просит отрисовать нужное окно и забирает содержимое буфера. Инструмент, умерший вместе с продуктом, вернулся в новой роли драйвера тестовой инфраструктуры. Ради таких вещей и стоит копаться в старом коде.
У Оперы по скриншотным рефтестам CSS Flexbox: 583 из 997 на Linux, 581 на Windows x64. Разница между платформами — это шрифты и субпиксельное сглаживание.
Хорошие новости: на 2012 год это был браузер, в чём-то даже опережавший время. Плохие новости — время неумолимо.
Теперь к внутренним тестам движка добавились десятки тысяч внешних проверок. И с этим возникают две проблемы.
Первая: каждый полный прогон — это от получаса и больше, и это только одна конфигурация.
Вторая: критерий приёмки «все тесты зелёные» не годится, потому что теперь есть «красные» тесты. Здесь есть соблазн забить и отключить приёмку, потому что «ну это нормально, починим всё, потом включим».
Первую проблему можно только принять как данность и запускать весь скоуп тестов только там, где он явно требуется.
Для второй же существует паттерн, позволяющий с ней бороться. Он называется «храповик» («ratchet»), и его смысл в том, чтобы фиксировать набор падений, следя за тем, чтобы их количество уменьшалось, но не росло. Как только «красных» тестов стало меньше, это требуется зафиксировать отдельно. Храповик крутится в одну сторону, ослабить его можно только явно.
В реализации учитывать нужно не только количество падающих тестов, но и какие тесты падают, а также связывать состояние тестов с коммитами. Паттерн вообще требует и аккуратности, и внимательности. Он не проверяет, что базовая линия правильная — только то, что она не понизилась.
И вот только после этого появилась возможность что-то добавить в сам движок. Вот что удалось сделать за неделю:
DOM и JS API.
MutationObserver с полной моделью записей и доставкой через микротаски.requestAnimationFrame/cancelAnimationFrame.requestIdleCallback/cancelIdleCallback с гарантией таймаута.URL — new URL(href, base) с изменяемыми компонентами, плюс createObjectURL/revokeObjectURL для Blob.TextEncoder/TextDecoder с потоковым декодированием и полной таблицей меток кодировок.navigator.sendBeacon.HTML.
srcset с плотностями, w-дескрипторы с sizes, <picture>/<source>.<iframe srcdoc>.CSS.
gap/row-gap/column-gap.inline-size, block-size, их min-/max--формы, margin-block-*/margin-inline-*.Canvas и SVG.
Path2D во всех трёх формах конструктора. ellipse().setLineDash/getLineDash/lineDashOffset.globalCompositeOperation поверх VEGA.writing-mode в легаси-значения SVG 1.1.Теперь в html5test уже 354 из 555, CSS Flexbox — 604 из 997.
Я сосредоточил усилия на кажущихся простыми изменениях, хотя и предположил, что где-то ошибусь с оценкой — так и вышло.
<iframe srcdoc> выглядит элементарно: у элемента есть атрибут с HTML-кодом, надо отдать его содержимое парсеру вместо загрузки по src.
Но в Presto документ рождается только из URL. DocumentManager загружает URL, кеш отдаёт байты, парсер их ест. Схемы «вот тебе строка, распарси её» не существует в принципе — это следствие сетевой архитектуры, где всё есть загрузка.
Может, попробовать сделать URL, отдающий нужные байты? Это оказался тупик: у внутренней схемы opera: есть свой генератор содержимого, и он перегенерирует содержимое кеша при каждом обращении. Попытка положить байты в кеш руками разбивается о URL_DataStorage::CreateCache(), который при создании кеша сбрасывает флаг «сгенерировано Оперой». То есть движок последовательно сопротивляется тому, чтобы URL был чем-то, кроме результата загрузки.
Настоящий механизм нашёлся в другом месте. У FramesDocument есть поле:
OpString wrapper_doc_source;
unsigned wrapper_doc_source_read_offset;
Это строка, которую документ скармливает парсеру вместо загруженных данных, откусывая по куску за раз. Сделана она была для «обёрточных» документов: когда вы открываете картинку, видео или плагин как отдельную страницу, движок синтезирует вокруг неё маленький HTML и парсит его отсюда. Ровно тот примитив, который нужен srcdoc.
Вторая половина задачи — безопасность. Документ srcdoc должен наследовать origin родителя, иначе он либо ничего не может, либо может слишком много. В Presto это решается изящно: URL объявляется «непригодным как контекст безопасности» — ровно так, как это уже сделано для about:blank, — и тогда origin берётся у referrer’а.
Path2D — это уже не про «нарисовать линию». Метод addPath(path, transform) принимает словарь DOMMatrix2DInit, у которого:
a и m11 — одно и то же поле, b и m12, и так далее;NaN равен NaN, а +0 равен -0);undefined» — разные вещи;ToNumber, а ToNumber может вызвать пользовательский valueOf.Последний пункт блокирует любую попытку наивной реализации. Нативная функция посреди своей работы должна повторно войти в интерпретатор и исполнить произвольный JS, который в принципе может сделать что угодно, включая уничтожение объекта, на котором вызван метод. Однопоточный движок с ручным стеком зачистки такого не переживает.
И тут выяснилось, что у Carakan для этого есть протокол. Нативная функция возвращает ES_NEEDS_CONVERSION, движок разматывает вызов, конвертирует аргументы сам — и перезапускает функцию с готовыми значениями. Функция при этом пишется так, будто конверсия уже произошла, хотя и обязана отличать первый вход от перезапуска.
Более того, у спецификатора аргументов есть форма для словарей:
ES_CONVERT_ARGUMENTS_AS(return_value,
"-{a:?n,b:?n,c:?n,d:?n,e:?n,f:?n,m11:?n,m12:?n,m21:?n,m22:?n,m41:?n,m42:?n}-");
n — «привести к числу». А ? перед типом означает «сохранить undefined как undefined, не превращая в NaN».
Различение «не задано» и «задано как undefined» — это семантика словарей Web IDL, которая понадобилась вебу лет через шесть после того, как Carakan был написан.
Совсем маленькая история, но она хорошо показывает подходы в этом коде.
В SVG 1.1 у writing-mode были свои ключевые слова: lr-tb, rl-tb, tb-rl. В современном CSS — horizontal-tb, vertical-rl, vertical-lr. Presto понимал и те, и другие, но приводил их к внутреннему перечислению, а вычисленный стиль потом печатал легаси-написание. Формально не ошибка, но на практике любой современный код, читающий getComputedStyle(el).writingMode, получал слово из спецификации 2003 года, причём результат зависел от пространства имён: на HTML то же самое свойство печаталось нормально.
Казалось бы, достаточно научиться переводить перечисление обратно. Не получается: SVGWRITINGMODE_TB — это одновременно и легаси tb, и современный vertical-lr. Отображение необратимо, по внутреннему значению уже не узнать, каким словом свойство задали. Значит, надо запомнить это отдельно.
А места под «ещё одно поле» тут нет: свойства SVG-текста лежат в упакованном битовом поле, и оно объединено в union с одним unsigned int, которым всё это инициализируется одним присваиванием. Бюджет — ровно 32 бита, и ни битом больше. Пришлось добавить двухбитный тег происхождения и растянуть поле с 26 бит до 28.
Простите, пожалуйста.
Делали ли инженеры старой школы ошибки? Делали, и даже знали об этом — в коде куча комментариев вроде «чую смрад, да не могу понять, что смердит».
А вот о чём они могли только догадываться — так это о проблемах неопределённого поведения:
delete по указателю, который ей не принадлежал;Они были сломаны всегда — оптимизаторы той эпохи не находили места, в которых UB превращается в разницу в поведении, а GCC 14 их уже находит.
Реставрация тем и отличается от раскопок, что заставляет лазить не туда, где интересно, а туда, куда ведёт задача — и по дороге попадается то, что не найти специально. Вот вам ещё находки.
Обфускация внутри закрытого исходника. В коде, который никогда не должен был стать публичным, разработчики Оперы всё равно прятали строки. Вот список сайтов, которым в режиме рендеринга для маленьких экранов разрешалось показывать iframe’ы (modules/doc/frm_doc.cpp:15524):
if (ShowIFrameInSSR("g%m!a%i##l.|g'oo@g!l!e$.#%c%o+m", urlname) || // allow iframes in gmail
ShowIFrameInSSR("w!!w%w#.goo%%g!%l#e.co'm/a++c#c<o|u<>n|ts/S#ervi#c+e!L@o##g%inB!ox", urlname) || // gmail login
ShowIFrameInSSR("m%s#n.c!o%%m", urlname) || // hotmail login
ShowIFrameInSSR("m%u!##r!s.<>163@.c%o!m", urlname)) // murs.163.com
Функция копирует строку и вычищает из неё мусорные символы ^'<>@ ?|!${[*#%()=+]}, после чего ищет подстроку в имени URL. То есть доменный whitelist специально хранится в изломанном виде, чтобы strings opera.exe не выдал наружу, что у браузера есть отдельные исключения для gmail и hotmail.
В соседней функции — сайты, чьи стили media=handheld признаны негодными:
dont_use_handheld_urls[0] = "m@s$n.c'o(m";
dont_use_handheld_urls[1] = "d@ag#b%la@de#t.n%o";
dont_use_handheld_urls[2] = "x#h<t*m#l.e<x#*pr*e#s*s<*en.*<s#e";
dont_use_handheld_urls[3] = "f(+or'b+r)uk+er.(n<o";
dont_use_handheld_urls[4] = "<v#g'.%n+o";
Расшифровывается это как msn.com, dagbladet.no, xhtml.expressen.se, forbruker.no, vg.no. MSN, две крупнейшие норвежские газеты, шведская Expressen и норвежский потребительский портал имели настолько плохую мобильную вёрстку, что её пришлось патчить в самом браузере.
Похожий подход — в поддержке проприетарного протокола операторских прокси Bytemobile (modules/url/protocols/ebo/bm_information_provider.cpp:36):
/**
* Shared secrets are xored to avoid clear existance inside opera binary
* Also they names are "obfuscated" and we do not want to optimize this part
*/
static const unsigned char dfkhfdsi[ BM_secretSize] =
Общие секреты лежат проксоренными по константе, имена переменных нарочно превращены в dfkhfdsi и lkdngied, а комментарий просит будущего сопровождающего не «оптимизировать» это место, то есть не расчищать намеренно наведённый бардак.
Трамплин, которого нет. В прошлой статье я рассказывал, как Windows x64-билд падал на любой странице: трамплин перехода из байткода в нативный код не соблюдал конвенцию вызовов Win64, но компилятором это не ловилось. Теперь я знаю причину — вот как этот трамплин существует в исходниках (modules/ecmascript/carakan/src/compiler/es_native_ia32.cpp):
/* These machine code arrays can be regenerated by defining the
DUMP_TRAMPOLINE_CODE_VECTORS macro below, running an empty script,
and passing the output through the script mangle-trampolines.py in
modules/ecmascript/carakan/src/scripts. */
const unsigned char cv_BytecodeToNativeTrampoline_sse2[] =
{
0x53, // push %rbx
0x55, // push %rbp
0x56, // push %rsi
...
0xb8, 0x01, 0x00, 0x00, 0x00, // mov $0x1,%eax
0xc3 // retq
};
Это не ассемблерная вставка — это массив байтов машинного кода с дизассемблерными комментариями. Написать __asm нельзя: MSVC на x86-64 инлайновый ассемблер не поддерживает. Поэтому трамплин один раз сгенерировали собственным кодогенератором, вывели дампом, прогнали через питоновский скрипт, аккуратно расставивший комментарии, и вклеили результат в исходник как «код». Дальше он живёт как снимок: отдельный массив под каждую архитектуру и каждую ABI, поддерживаемый руками. В такой конструкции неверная версия под одну из платформ может пролежать годами, потому что никакой компилятор её не проверяет.
Честно говоря, понятия не имею, как бы я сейчас решил эту задачу.
Tragedy! Тег <blink> в Presto — это не CSS-анимация и не таймер на документ. Это глобальная подсистема: WindowManager считает, сколько открытых документов содержит мигающие элементы, и держит один общий секундный таймер, включая его, только когда мигать действительно есть чему. А если этот таймер не удалось создать из-за нехватки памяти, случается настоящая трагедия (modules/dochand/winman.cpp:1057):
OP_ASSERT(FALSE);
// Have no idea how to handle this. If posting the
// message fails, we will not get called again, and opera won't
// have blinking elements. Tragedy!
И человеческий слой. В первой статье я собирал смешные комментарии. Эти — не смешные, а показывающие, как там было устроено.
Порядок выключения модулей при завершении работы (modules/url/url_module.cpp:198):
// Some parts of libssl needs to be shut down before url internals
// shut down, in particular before the server name database is
// shut down.
//
// This is a temporary workaround pending some sort of redesign
// that eliminates the problem properly. It was implemented here
// by and because of decisions made by the architecture group.
g_opera->libssl_module.InterModuleShutdown();
Инженер вставил вызов не там, где считал правильным, и оставил в коде указание, кто именно так решил.
Обсуждение модели безопасности в modules/security_manager/documentation/pending-models.txt — это переписка, скопированная в папку документации как есть:
> \> Finns det möjlighet att lägga in detta i din security module så
> \> den delar kod med jsplugins när nu jsplugins säkerhetsmodellen
> \> blir flyttad?
>
> Det ville være naturlig.
>
> \> På core-1 ligger implementationen på: dumdum_2_final_1_patch_3 i
> \> filerna dom/src/dom_manager.h/.cpp
>
> OK, skal ihvertfall skrive det på lista over ting jeg kan flytte,
> men om du får ånden over deg må du gjerne flytte koden og sende
> meg en PATCH.
Один спрашивает по-шведски, другой отвечает по-норвежски, и оба прекрасно понимают друг друга. «Если на тебя найдёт вдохновение — перенеси код сам и пришли мне патч». А ветка с реализацией называется dumdum_2_final_1_patch_3 — узнаваемо для любого, кто когда-нибудь называл файлы final_v2_итог_правки3.
Поисковая машина внутри браузера. Модуль modules/search_engine описывает себя так:
Search engine provides database and full-text functionality for various indexing/searching tasks in Opera, such as visited pages search or cache management. The footprint is roughly 100KB.
Сто килобайт — это, между прочим, блочное хранилище с журналированием, B-деревья, собственный сжатый префиксный индекс ACT, сегментатор слов с отдельной обработкой CJK, тайского, лаосского и тибетского, свой компрессор строк и курсоры над таблицами. Полноценная СУБД с полнотекстовым поиском — ради страницы opera:historysearch, которая ищет по тексту всех страниц, которые вы когда-либо открывали. Локально, без облака, в 2006 году.
А в modules/search_engine/documentation/presentation/ лежит презентация про этот модуль — с планом, картинками и шпаргалкой докладчика. Оттуда:
> Google — 2 B-trees, sorted by ID and by ranking. Go from the top rankings and cross-check the IDs.
> UniCompressor — (LZO 35x faster than zlib) 10% worse compression and 25% slower than LZO.
> Demo (unpack https://ssl.opera.com:8004/pavels/search_engine/presentation/demo/install.zip to C:)
> run C:\presentation\wingogi_debug_desktop.exe
> goto www.opera.com / click at screenshots / goto opera:historysearch / type ellen and click search
В репозитории сохранился не только код, но и то, как инженер показывал его коллегам — вплоть до слов «наберите ellen и нажмите поиск».
В папке иллюстраций к слайду про инвертированный индекс лежит файл bush.jpg. Я открыл его, ожидая увидеть Ванневара Буша — автора идеи, из которой выросли и гипертекст, и индексирование. Это оказался Джордж Буш-младший, хохочущий в кресле.
Что дальше?
Главный барьер — ES2015. Когда страница подключает скрипт, в первой же строке которого встречается стрелочная функция, class или let — парсер отваливается на синтаксической ошибке, и весь скрипт не исполняется вообще. Не «работает хуже», а не работает. Полифиллами это не лечится, придётся трогать сам Carakan — его парсер, байткод и, наверняка, JIT.
Дальше по списку: CSS Grid, кастомные свойства, Shadow DOM, HTTP/2… Многопроцессный каркас, который в Presto есть, но не дописан.
Ждёт своего времени куча легаси: к Hunspell добавился gstreamer (история сродни апгрейду OpenSSL, но большего масштаба), компилятор шейдеров и ещё всякое.
Это правда гигантский проект, и как бы хорошо он ни был спроектирован изначально, он требует исключительной координации. Даже с помощью ИИ-агентов работать над восстановлением Оперы можно бесконечно — и я, естественно, не могу дать гарантий, что работа будет продолжаться.
Но всё-таки: в прошлый раз я закончил тем, что не знаю, можно ли научить старую Оперу новым трюкам.
Теперь знаю, что можно.