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

Комментарии (1)


  1. nin-jin
    28.08.2025 08:49

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