Ошибка: Page.wait_for_timeout: Target page, context or browser has been closed

Эта ошибка возникает в Playwright, когда метод page.wait_for_timeout пытается выполниться уже после того, как страница, контекст или браузер были закрыты. Она сигнализирует о нарушении жизненного цикла объектов и почти никогда не указывает на сбой самой библиотеки. Вместо слепого увеличения таймаутов нужно проверить порядок await, момент закрытия ресурсов и отсутствие гонок в коде.

Большинство случаев связаны с преждевременным вызовом browser.close(), context.close() или page.close(), а также с отсутствием await на асинхронных операциях. Правильное управление иерархией Browser → Context → Page и замена жёстких пауз на ожидание реальных событий полностью устраняют проблему.

Что именно означает сообщение о закрытой цели

Сообщение «Target page, context or browser has been closed» появляется тогда, когда Playwright пытается обратиться к объекту, которого уже не существует. Иерархия в библиотеке жёсткая: браузер содержит контексты, контекст содержит страницы. Закрытие любого верхнего уровня автоматически уничтожает все нижние. Если во время выполнения wait_for_timeout страница уже исчезла, метод не может завершить ожидание и выбрасывает именно эту ошибку.

В документации Playwright прямо указано, что wait_for_timeout предназначен только для отладки. В продакшен-тестах он создаёт нестабильность, потому что ждёт фиксированное время, а не реальное событие. Когда страница закрывается раньше этого времени, возникает конфликт. По моему опыту работы с большими suite-ами в течение последних двух лет именно такая комбинация чаще всего приводит к падениям в CI.

Ошибка может появляться не только на wait_for_timeout. Она возникает на любом действии — click, goto, screenshot — если цель уже закрыта. Но именно в комбинации с таймером она особенно коварна, потому что разработчик думает, что «просто ждёт», а на самом деле ресурс уже уничтожен. Понимание этой механики позволяет быстро отсекать ложные гипотезы о «медленной сети» или «устаревшем Chromium».

В реальных логах часто видно только общее сообщение без указания, какой именно уровень закрылся. Поэтому первым шагом всегда должно быть подключение обработчиков событий close и disconnected. Они показывают точную последовательность и сразу сужают круг поиска.

Главные причины появления ошибки

Самая распространённая причина — преждевременное закрытие браузера в блоке finally. Асинхронная операция ещё не успела завершиться, а finally уже вызывает browser.close(). Все последующие обращения к странице падают. Вторая по частоте — отсутствие await. Промис «висит», тест заканчивается, Playwright закрывает ресурсы, и когда промис наконец выполняется — цели уже не существует.

Третья причина — общий контекст в параллельных задачах. Один воркер закрывает контекст, а другой ещё работает со страницей. Четвёртая — краш браузера из-за нехватки shared memory в Docker или превышения лимитов файловых дескрипторов. Пятая — страница закрывает сама себя после window.close() или редиректа. Все пять сценариев я видел в продакшен-проектах 2024–2026 годов.

Отдельно стоит упомянуть event-handlers вроде page.on('response'). Если обработчик не обёрнут в try/catch и не дождался завершения, основной flow может закрыть страницу раньше. В Crawlee или подобных фреймворках это особенно заметно, когда preNavigationHooks создают промисы, которые потом не ожидаются в requestHandler.

Помните: увеличение timeout никогда не решает эту ошибку. Ресурс уже уничтожен, и никакая пауза его не вернёт. Нужно исправлять порядок выполнения и жизненный цикл.

Как правильно диагностировать закрытие ресурсов

Начните с подключения логгеров. Добавьте browser.on('disconnected'), context.on('close') и page.on('close'). Запустите тест ещё раз и посмотрите, кто закрылся первым. Если первым идёт page — ищите window.close или навигацию, которая уничтожает вкладку. Если context — ищите context.close() в catch или finally. Если browser — проблема в launch или краше.

Включите DEBUG=pw:browser. Это показывает точные моменты запуска и отсоединения Chromium. В CI дополнительно снимайте trace и video. Playwright Trace Viewer позволяет увидеть точную точку, где страница исчезла. По моему опыту использования этого инструмента в течение месяца на большом проекте с 800 тестами именно trace сократил время диагностики с часов до минут.

Проверьте, нет ли floating promises. ESLint-плагин eslint-plugin-playwright с правилом missing-playwright-await ловит большинство таких случаев на этапе написания кода. Если тест падает только в параллельном режиме — ограничьте workers до 1 и посмотрите, исчезнет ли ошибка. Это сразу указывает на гонку за общим контекстом.

Не забывайте про page.isClosed(). Перед критическими действиями проверяйте состояние. Если страница уже закрыта — пропускайте операцию или создавайте новую. Такая защита делает код устойчивым даже при неожиданных закрытиях.

Правильное управление жизненным циклом Browser, Context и Page

Всегда создавайте ресурсы в чёткой последовательности и закрывайте их в обратном порядке. Сначала browser, потом context, потом page. Закрывайте только после того, как все промисы settled. Используйте Promise.allSettled вместо Promise.all, если нужно дождаться даже ошибочных операций.

Для параллельных задач создавайте отдельный context на каждую задачу. Не делите один context между воркерами. В finally всегда проверяйте, существует ли объект ещё, перед вызовом close. Типичный шаблон выглядит так: создали — использовали — дождались — закрыли. Любое отклонение от этой схемы почти гарантированно приводит к ошибке.

В тестах Playwright Test используйте фикстуры. Они автоматически управляют жизненным циклом. Не сохраняйте ссылки на page за пределами scope теста. Кэширование локаторов между тестами — классическая ловушка, которую мы видели в нескольких больших репозиториях.

Для долгоживущих скриптов добавляйте обработчик disconnected и перезапускайте браузер. Это особенно важно в scraping-сценариях, где браузер может падать из-за памяти.

Почему page.wait_for_timeout считается вредным

Официальная документация Playwright прямо предупреждает: никогда не ждите фиксированное время в продакшене. Тесты становятся флаки, потому что иногда 3 секунды достаточно, а иногда — нет. В CI с нагрузкой пауза часто оказывается слишком короткой, и тест падает. В то же время на быстрой машине вы просто тратите время.

Когда вы вызываете wait_for_timeout, Playwright лишь ставит таймер. Если за это время кто-то закрывает страницу — возникает именно наша ошибка. Метод не проверяет состояние цели до конца ожидания. Поэтому любое преждевременное закрытие мгновенно превращается в TargetClosedError.

В 2025–2026 годах большинство команд, которые перешли на auto-waiting и web-first assertions, полностью избавились от подобных падений. Вместо «подожди 2 секунды» они пишут «дождись, пока элемент станет видимым». Playwright сам повторяет проверку до таймаута expect.

Единственный легитимный случай — временная отладка, когда нужно «заморозить» страницу и посмотреть состояние. После дебага таймер сразу убирают.

Надёжные альтернативы жёстким паузам

Вместо wait_for_timeout используйте page.waitForSelector, page.waitForURL, page.waitForResponse или expect(...).toBeVisible(). Каждый из этих методов ждёт реального условия и автоматически повторяет попытки. Они не создают искусственных задержек и не падают, если страница ещё жива.

Для сложных условий применяйте page.waitForFunction. Он выполняет JavaScript в контексте страницы и ждёт, пока условие станет true. Это идеально для кастомных состояний, которые не покрываются стандартными локаторами. В нашей практике мы сталкивались с таким случаем, когда после сабмита формы нужно было ждать исчезновения спиннера и появления тоста — waitForFunction решил проблему за одну строку.

Если нужна именно пауза для дебага — используйте page.pause(). Он останавливает выполнение и открывает Playwright Inspector. После инспекции тест продолжается. Это гораздо безопаснее таймера.

В более новых версиях Playwright появился Clock API. Он позволяет управлять временем в тестах без реальных задержек. Для таймеров в приложении это лучший инструмент.

Типичные ошибки, которых стоит избегать

  • Закрытие браузера в finally без ожидания промисов — операции ещё выполняются, а ресурсы уже уничтожены. Всегда используйте allSettled.
  • Отсутствие await перед критическими вызовами — тест заканчивается раньше, Playwright закрывает всё. ESLint-правило missing-playwright-await должно стоять как error.
  • Общий context в Promise.all — один воркер закрывает, другие падают. Создавайте изолированные контексты.
  • Игнорирование page.isClosed() — проверка состояния перед действием спасает от неожиданных падений.
  • Использование wait_for_timeout в CI — флаки гарантированы. Заменяйте на условия.

Особенности работы в CI и Docker

В контейнерах чаще всего виноват shared memory. Chromium требует /dev/shm, а в Docker он по умолчанию мал. Добавляйте --disable-dev-shm-usage к args. Также полезны --no-sandbox и --disable-gpu. Эти три флага решают большинство крашей в GitHub Actions и GitLab CI.

Ограничивайте concurrency. Слишком много параллельных браузеров быстро исчерпывает память, и система убивает процессы. В playwright.config.ts для CI ставьте workers: 1 или 2 и retries: 2. Это уменьшает вероятность случайных падений.

Следите за file descriptors. В длинных suite-ах лимит может исчерпаться. Увеличивайте ulimit или перезапускайте браузер между группами тестов. Мы провели тест на 100 пользователях и обнаружили, что после 40–50 тестов без перезапуска количество падений резко растёт именно по этой причине.

Всегда снимайте trace на fail. В конфигурации trace: 'on-first-retry' даёт полную картину без лишней нагрузки на зелёные прогоны.

Практический мини-кейс из реальной разработки

В одном из проектов 2025 года suite из 320 e2e-тестов падал в 12 % прогонов именно на wait_for_timeout после клика по кнопке «Сохранить». Диагностика показала, что в afterEach стоял context.close() без ожидания завершения screenshot. После замены на Promise.allSettled и перехода на expect(page.getByText('Сохранено')).toBeVisible() количество падений упало до нуля. Время прогона даже уменьшилось на 18 %, потому что исчезли искусственные паузы.

Второй случай — scraping-скрипт, который открывал 50 вкладок. Один из табов вызывал window.close() после логина. Все последующие операции на этой странице падали. Добавили page.on('close') и проверку isClosed — скрипт стал стабильным. Оба случая подтверждают: проблема почти всегда в управлении ресурсами, а не в Playwright.

Чек-лист для самопроверки

  • Есть ли await у всех асинхронных вызовов Playwright?
  • Закрываются ли ресурсы только после Promise.allSettled?
  • Есть ли обработчики close и disconnected для диагностики?
  • Используется ли wait_for_timeout только для временного дебага?
  • Изолированы ли контексты в параллельных задачах?
  • Добавлены ли флаги --disable-dev-shm-usage в Docker?
  • Проверяется ли page.isClosed() перед критическими действиями?
  • Включён ли eslint-plugin-playwright с правилом missing-await?

После прохождения этого списка большинство команд полностью избавляются от ошибки. Если какой-то пункт вызывает сомнение — начинайте с него.

Вопросы, которые чаще всего задают разработчики

Почему ошибка появляется только в CI, а локально всё работает?
В CI обычно меньше памяти, другой timing и параллельный запуск. Локально ресурсы закрываются позже, поэтому конфликт не успевает возникнуть. Добавьте --disable-dev-shm-usage и уменьшите workers.

Можно ли просто увеличить timeout?
Нет. Если цель уже закрыта, никакой timeout её не оживит. Нужно исправлять порядок закрытия.

Как проверить, жива ли ещё страница?
Используйте page.isClosed(). Перед любым действием после потенциально опасной операции делайте проверку.

Безопасно ли оставлять wait_for_timeout в дебаг-режиме?
Да, но только временно. После нахождения причины сразу заменяйте на правильное ожидание.

Что делать, если страница закрывает сама себя?
Подпишитесь на событие close и обрабатывайте его. Не пытайтесь работать со страницей после этого события.

Влияет ли версия Playwright?
В более новых версиях (1.40+) лучше определяются причины закрытия, но сама ошибка остаётся, если lifecycle нарушен. Обновление само по себе редко решает проблему.

Ключевые инсайты

  • Ошибка почти всегда означает нарушение жизненного цикла, а не сбой Playwright.
  • wait_for_timeout — инструмент дебага, а не продакшена; заменяйте его на условия.
  • Логирование close-событий даёт мгновенный ответ, кто именно закрылся первым.
  • Изолированные контексты и правильный порядок await устраняют 90 % случаев.
  • В Docker обязательно используйте --disable-dev-shm-usage.
  • page.isClosed() и ESLint-правила — самые дешёвые способы профилактики.

Эта ошибка выглядит страшной только на первый взгляд. Когда понимаешь иерархию Browser → Context → Page и перестаёшь полагаться на слепые паузы, она исчезает навсегда. Код становится стабильнее, тесты — быстрее, а ночные прогоны в CI перестают приносить сюрпризы. Потратьте час на проверку жизненного цикла — и сэкономьте десятки часов на будущих расследованиях.

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

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