Async stack trace

При асинхронных вызовах синхронный стек теряется — между двумя await-ами стек "обнуляется", и в обычной ситуации Error.stack не показывает, откуда async-функция была вызвана. В V8 (Node 12 / V8 7.2+) проблему починили --async-stack-traces.

Что это / Зачем

Когда асинхронная функция возобновляется после await или коллбэк вызывается из event loop, его call stack начинается «с нуля» — все верхние фреймы уже сняты. Без специальной поддержки рантайма ошибка содержит только локальный стек, а связь с вызывающим кодом теряется.

Это критично для отладки: видишь ENOENT где-то в глубине, но не знаешь, какой пользовательский код это запустил.

API / Синтаксис

// Симптом: ошибка не попадает в catch
const cause = () => { throw new Error('boom'); };
const f = async () => {
  setTimeout(cause, 0); // throw полетит из event loop, await его не поймает
};

try { await f; }
catch (e) { console.log('catch'); } // НЕ выполнится

// async stack traces — встроены с Node 12
//   node --async-stack-traces script.js   (в Node 12+ включено по умолчанию)
async function step1() { await step2; }
async function step2() { await Promise.reject(new Error('oops')); }
try { await step1; }
catch (e) { console.log(e.stack); }
// Видно цепочку: step2 → step1 → top-level (благодаря async stack traces)

Ключевые моменты

  • В коллбэк-стиле (setTimeout(cb), fs.readFile(path, cb)) стек теряется ВСЕГДА — Error.stack внутри cb не содержит вызвавшего кода.
  • В async/await V8 хранит "logical stack" между await — после возобновления он собирается обратно.
  • Флаг V8 --async-stack-traces (Node 12+, включён по умолчанию) расширяет стек цепочкой await-ов.
  • Throw из чистого callback'а не превращается в reject — try/catch снаружи async-функции его не поймает.
  • Throw из async-функции возвращается через её Promise → ловится try/catch с await или .catch.

Подводные камни

  • Если используете callback-стиль (legacy lib) — стек всё ещё теряется, async stack traces не помогают.
  • Библиотеки вроде longjohn / trace-and-clarify искусственно склеивают трейсы — даёт overhead, в проде включать не стоит.
  • Реальная цепочка может быть длинной — V8 ограничивает глубину async stack по умолчанию ~10 фреймов.
  • В Node 11 и младше — старый V8 без async stack traces, отлаживать тяжело.

🎓 Источники

  • 🎓 [Проблема асинхронного стектрейса в JavaScript и Node.js] · 2019-03-21 · YouTube
    • Тезисы:
      • До Node 12 / V8 7.2 async-цепочка в стеке полностью терялась.
      • Throw из callback не попадает в try/catch снаружи — оборачивается в Unhandled Rejection в лучшем случае.
      • async-функции дают видимый stack даже без таймеров (поскольку throw превращается в reject Promise).
      • С таймером (callback-style) стек всё равно не восстанавливается.
      • Сторонние библиотеки склеивают стеки за счёт overhead'а.
    • Цитата:

      «Один из самых неприятных моментов асинхронного программирования — потеря стека вызовов. Между двумя await-ами стек по сути обнуляется.»

См. также