Расследуем самое длинное issue в Jest

от автора

Привет! Меня зовут Никита, я старший фронтенд-инженер в 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 обернулась болью для команд с большим количеством тестов: кто-то уменьшал количество воркеров, кто-то оптимизировал импорты, ведь чем больше импортов, тем больше кэш. В общем, всем пришлось как-то выкручиваться, чтобы тесты заново начали проходить. Помните, что как бы идеально не был написан ваш код, ваши инструменты могут создать вам и вашей команде проблемы, которые могут длиться несколько лет.


ссылка на оригинал статьи https://habr.com/ru/articles/933884/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *