Привет! Меня зовут Никита, я старший фронтенд-инженер в Ozon Tech, и я разрабатываю кабинет рекламодателя. Однажды мы попытались обновить версию Node.js, и у нас начали рандомно падать тесты в CI/CD. Как выяснилось позже — из-за нехватки памяти. Так как над нашим проектом трудятся 15 фронтенд-разработчиков, эта проблема сильно замедляла процесс выкатки, и разработчикам приходилось вручную перезапускать тесты, пока они не начинали проходить, что также ухудшало developer experience.
Мы быстро решили проблему откаткой версии, но хотелось докопаться до того, из-за чего это произошло. В этой статье мы увидим, как минорное обновление версии сможет породить баг, который затянется на два года и вовлечёт в себя команды Jest, Node.js и V8.
Как всё начиналось
Нам всегда хочется иметь актуальные технологии, чтобы код мог выполняться быстрее, и чтобы мы могли использовать все современные особенности наших библиотек. Освободившись от натиска бизнесовых задач, мы решили в рамках техдолга обновить версию Node.js с 16.10 на 18, так как поддержка 16-ой версии ноды уже подходила к концу.

Мы заметили прирост в скорости билда и выполнения тестов, но некоторые джобы с тестами периодически стали падать из-за того, что у раннера кончалась память, а так как у нас написано почти 3000 unit-тестов, для нас это стало серьёзной проблемой.
Как воспроизвести баг
Чтобы воспроизвести проблему, нам понадобятся Jest версии 27.x и Node.js версии 16.11. Запускаем тесты с флагами node --expose-gc ./node_modules/.bin/jest --logHeapUsage
и видим, как потребление памяти начинает расти.

Давайте разберёмся, что же стало причиной этой проблемы.
Проблемы с Jest
Раньше Jest выполнял код вот так:
runSourceText(sourceText, filename) {
return this.global.eval(sourceText + '\n//# sourceURL=' + filename);
}
Он мог потреблять только commonjs-модули и вызывать eval этого скрипта в среде, в которой запускался. Но с течением времени многое поменялось, и теперь для запуска скриптов Jest использует виртуальную машину в Node.js, и код, который запускает тесты, теперь выглядит так:
private createScriptFromCode(scriptSource: string, filename: string) {
try {
const scriptFilename = this._resolver.isCoreModule(filename)
? `jest-nodejs-core-${filename}`
: filename;
return new Script(this.wrapCodeInModuleWrapper(scriptSource), {
displayErrors: true,
filename: scriptFilename,
// @ts-expect-error: Experimental ESM API
importModuleDynamically: async (specifier: string) => {
invariant(
runtimeSupportsVmModules,
'You need to run with a version of node that supports ES Modules in the VM API. See https://jestjs.io/docs/ecmascript-modules',
);
const context = this._environment.getVmContext?.();
invariant(context, 'Test environment has been torn down');
const module = await this.resolveModule(
specifier,
scriptFilename,
context,
);
return this.linkAndEvaluateModule(module);
},
});
} catch (e) {
throw handlePotentialSyntaxError(e);
}
}
Поскольку Jest перехватывает импорты модулей, чтобы они могли быть замоканы, Jest также вынужден передавать опцию importModuleDynamically
, чтобы обрабатывать динамические импорты в коде. Как мы видим по коду, эта опция передаётся для всех скриптов, даже для тех, которые не используют динамические импорты. Давайте посмотрим, как это реализовано в Node.js.
Реализация в Node.js
Для обработки динамических импортов Node.js должна настраивать hostDefinedOptions
, которые являются контекстом, содержащим информацию о том, где вызван динамический импорт. Они также являются полем referrer в спецификации:

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

В Node.js в качестве hostDefinedOptions
используется Symbol
из имени скрипта:
//https://github.com/nodejs/node/blob/5bea645e4b1ff9b740acf24cfb899fb099ba1065/lib/vm.js#L111
class Script extends ContextifyScript {
constructor(code, options = kEmptyObject) {
...
const hostDefinedOptionId =
getHostDefinedOptionId(importModuleDynamically, filename);
// Calling `ReThrow()` on a native TryCatch does not generate a new
// abort-on-uncaught-exception check. A dummy try/catch in JS land
// protects against that.
try { // eslint-disable-line no-useless-catch
super(code,
filename,
lineOffset,
columnOffset,
cachedData,
produceCachedData,
parsingContext,
hostDefinedOptionId);
} catch (e) {
throw e; /* node-do-not-add-exception-line */
}
...
}
}
Далее vm.Script
из Node.js преобразуется в v8::UnboundScript
, в котором создаётся v8::internal::SharedFunctionInfo
и затем сохраняется в таблицу кэша.
//https://github.com/nodejs/node/blob/main/src/node_contextify.cc#L1044
void ContextifyScript::New(const FunctionCallbackInfo<Value>& args) {
...
// Инициализация ScriptOrigin
Local<PrimitiveArray> host_defined_options =
PrimitiveArray::New(isolate, loader::HostDefinedOptions::kLength);
host_defined_options->Set(
isolate, loader::HostDefinedOptions::kID, id_symbol);
ScriptOrigin origin(filename,
line_offset, // line offset
column_offset, // column offset
true, // is cross origin
-1, // script id
Local<Value>(), // source map URL
false, // is opaque (?)
false, // is WASM
false, // is ES Module
host_defined_options);
...
// Инициализация UnboundScript
MaybeLocal<UnboundScript> maybe_v8_script =
ScriptCompiler::CompileUnboundScript(isolate, &source, compile_options);
Local<UnboundScript> v8_script;
if (!maybe_v8_script.ToLocal(&v8_script)) {
errors::DecorateErrorStack(env, try_catch);
no_abort_scope.Close();
if (!try_catch.HasTerminated())
try_catch.ReThrow();
TRACE_EVENT_END0(TRACING_CATEGORY_NODE2(vm, script),
"ContextifyScript::New");
return;
}
contextify_script->set_unbound_script(v8_script);
std::unique_ptr<ScriptCompiler::CachedData> new_cached_data;
if (produce_cached_data) {
new_cached_data.reset(ScriptCompiler::CreateCodeCache(v8_script));
}
}
До версии 16.11 это не вызывало никаких проблем, но в этой версии была обновлена версия V8, которая начала обрабатывать эту опцию. В итоге, когда Jest передавал функцию importModuleDynamically
, вызываемый скрипт не попадал в кэш, так как у него были другие hostDefinedOptions
. Это приводило к лишним затратам времени на выполнение, а также этот скрипт попадал в кэш со старым исходным кодом и с новыми hostDefinedOptions
, что и приводило к утечке памяти.
Реализация в V8
Когда V8 компилирует скрипт, он создаёт внутреннюю структуру SharedFunctionInfo
, которая является внутренним представлением скрипта.

Это нужно, чтобы передавать информацию о скрипте в разные его инстансы. И как раз SharedFunctionInfo
и хранится в кэше. До добавления обработки hostDefinedOptions, кэш проверял, что данные импортируемого скрипта совпадают с данными скрипта в кэше и проблем с кэшем не было.
bool HasOrigin(Isolate* isolate, Handle<SharedFunctionInfo> function_info,
const ScriptDetails& script_details) {
Handle<Script> script =
Handle<Script>(Script::cast(function_info->script()), isolate);
// If the script name isn't set, the boilerplate script should have
// an undefined name to have the same origin.
Handle<Object> name;
if (!script_details.name_obj.ToHandle(&name)) {
return script->name().IsUndefined(isolate);
}
// Do the fast bailout checks first.
if (script_details.line_offset != script->line_offset()) return false;
if (script_details.column_offset != script->column_offset()) return false;
// Check that both names are strings. If not, no match.
if (!name->IsString() || !script->name().IsString()) return false;
// Are the origin_options same?
if (script_details.origin_options.Flags() !=
script->origin_options().Flags()) {
return false;
}
// Compare the two name strings for equality.
return String::Equals(isolate, Handle<String>::cast(name),
Handle<String>(String::cast(script->name()), isolate));
}
Но после того как hostDefinedOptions
начали обрабатываться, появились утечки памяти, так как hostDefinedOptions,
передаваемые из Node.js, всегда были разными и скрипт не попадал в кэш.
Кроме этого, в V8 не совсем правильно обрабатывается referer
динамически импортируемого скрипта, что может приводить к интересным багам при использовании динамических импортов с относительными путями.
За ходом исправления динамических импортов в V8 вы можете следить тут.
Решение проблемы
Проблему вызвалась решить https://github.com/joyeecheung из команды Node.js , так как в V8 исправить этот баг сложнее, потому что изменения затронут не только виртуальную машину Node.js, но и реально работающий код.
Первая часть решения была для случаев, когда importModuleDynamically
не использовался, решение заключалось в том чтобы передавать Symbol
в качестве hostDefinedOptions
, фикс был добавлен тут. Но, как мы помним, Jest всегда передаёт эту опцию, для чего во второй части решения проблемы было добавлено возвращение другого Symbol
в качестве hostDefinedOptions
, когда опция –expiremental-vm-modules
, которая позволяет использовать динамические импорты в vm Node.js, была отключена и динамический импорт не вызывался. В случае если динамический импорт будет вызван — этот Symbol
приводил бы к ошибке. Данный фикс был добавлен тут.
Изменения дошли до нас аж в версии 20.8.0, и только тогда наша команда смогла обновить версию Node.js, а команда Jest смогла закрыть ишью с почти 500 сообщениями .
Что в итоге
Простая задача по обновлению версии Node.js обернулась болью для команд с большим количеством тестов: кто-то уменьшал количество воркеров, кто-то оптимизировал импорты, ведь чем больше импортов, тем больше кэш. В общем, всем пришлось как-то выкручиваться, чтобы тесты заново начали проходить. Помните, что как бы идеально не был написан ваш код, ваши инструменты могут создать вам и вашей команде проблемы, которые могут длиться несколько лет.
nin-jin
Тем временем, как происходит тестирование в $mol: сборщик рекурсивно собирает список модулей, деликатно разрывает циклические зависимости, сортирует, из каждого модуля вытягивает тесты, соединяет их в один бандл и кладёт его на диск. Всё, теперь эти тесты запускаются через ноду/браузер, как любой другой скрипт, максимально приближенно к исполнению на проде: ни динамических импортов, ни вируальных машин, ни эвалов.