From df715cd5736717f68cf4bf6ca926fb76ad6be74e Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Tue, 18 Aug 2026 16:33:37 -0700 Subject: [PATCH 1/3] fix(asyncio): do not hang when async_playwright() is cancelled while connecting Cancelling __aenter__ left the driver process and the transport tasks running with no owner. At loop shutdown the init task absorbed its one cancellation inside Channel._abort() waiting for a reply that could never arrive, so asyncio.run() never returned. - stop the connection when __aenter__ is cancelled - allow PipeTransport.request_stop() before the driver has spawned - release pending protocol callbacks when the Connection.run() task is cancelled at loop shutdown - retrieve callback exceptions in Channel._abort() even when its wait is interrupted by another cancellation Fixes: https://github.com/microsoft/playwright/issues/42296 --- playwright/_impl/_connection.py | 25 ++++++++++--- playwright/_impl/_transport.py | 14 +++++-- playwright/async_api/_context_manager.py | 16 ++++++-- tests/async/test_asyncio.py | 47 ++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 13 deletions(-) diff --git a/playwright/_impl/_connection.py b/playwright/_impl/_connection.py index 57a9dcf6d..ae65bc2ac 100644 --- a/playwright/_impl/_connection.py +++ b/playwright/_impl/_connection.py @@ -353,7 +353,16 @@ async def init() -> None: await self._transport.connect() self._init_task = self._loop.create_task(init()) - await self._transport.run() + try: + await self._transport.run() + except asyncio.CancelledError: + # This task is cancelled by asyncio.run() at loop shutdown. Release + # any tasks waiting on protocol replies that will never arrive, + # otherwise their cancellation is absorbed by Channel._abort() + # waiting for the reply and the loop never finishes closing. + if not self._closed_error: + self.cleanup() + raise def stop_sync(self) -> None: self._transport.request_stop() @@ -463,7 +472,7 @@ async def _abort( except (Error, OSError): pass try: - done, _ = await asyncio.wait( + await asyncio.wait( { self._transport.on_error_future, callback.future, @@ -471,11 +480,17 @@ async def _abort( return_when=asyncio.FIRST_COMPLETED, ) finally: + # Retrieve exceptions in a finally so that this happens even if the + # wait above is interrupted by another cancellation (e.g. when the + # loop is being shut down), avoiding 'Future exception was never + # retrieved' errors. if not callback.future.done(): callback.future.cancel() - for future in done: - if not future.cancelled(): - future.exception() + elif not callback.future.cancelled(): + callback.future.exception() + error_future = self._transport.on_error_future + if error_future.done() and not error_future.cancelled(): + error_future.exception() def dispatch(self, msg: ParsedMessagePayload) -> None: if self._closed_error: diff --git a/playwright/_impl/_transport.py b/playwright/_impl/_transport.py index 3cc029e18..7aa8803ed 100644 --- a/playwright/_impl/_transport.py +++ b/playwright/_impl/_transport.py @@ -92,18 +92,20 @@ class PipeTransport(Transport): def __init__(self, loop: asyncio.AbstractEventLoop) -> None: super().__init__(loop) self._stopped = False + self._output: Optional[asyncio.StreamWriter] = None + self._stopped_future: asyncio.Future = loop.create_future() def request_stop(self) -> None: - assert self._output self._stopped = True - self._output.close() + # The stop can be requested while connect() is still spawning the + # driver; connect() will close the pipe once it is available. + if self._output: + self._output.close() async def wait_until_stopped(self) -> None: await self._stopped_future async def connect(self) -> None: - self._stopped_future: asyncio.Future = asyncio.Future() - try: # For pyinstaller and Nuitka env = get_driver_env() @@ -129,10 +131,14 @@ async def connect(self) -> None: startupinfo=startupinfo, ) except Exception as exc: + if not self._stopped_future.done(): + self._stopped_future.set_result(None) self.on_error_future.set_exception(exc) raise exc self._output = self._proc.stdin + if self._stopped and self._output: + self._output.close() async def run(self) -> None: assert self._proc.stdout diff --git a/playwright/async_api/_context_manager.py b/playwright/async_api/_context_manager.py index 0c93f7043..7e807632e 100644 --- a/playwright/async_api/_context_manager.py +++ b/playwright/async_api/_context_manager.py @@ -37,10 +37,18 @@ async def __aenter__(self) -> AsyncPlaywright: loop.create_task(self._connection.run()) playwright_future = self._connection.playwright_future - done, _ = await asyncio.wait( - {self._connection._transport.on_error_future, playwright_future}, - return_when=asyncio.FIRST_COMPLETED, - ) + try: + done, _ = await asyncio.wait( + {self._connection._transport.on_error_future, playwright_future}, + return_when=asyncio.FIRST_COMPLETED, + ) + except asyncio.CancelledError: + # Cancelled while connecting - stop the driver process and the + # background tasks, otherwise they keep running with no owner. + if not playwright_future.done(): + playwright_future.cancel() + await self._connection.stop_async() + raise if not playwright_future.done(): playwright_future.cancel() playwright = AsyncPlaywright(next(iter(done)).result()) diff --git a/tests/async/test_asyncio.py b/tests/async/test_asyncio.py index 2c90cb494..a346189e8 100644 --- a/tests/async/test_asyncio.py +++ b/tests/async/test_asyncio.py @@ -144,6 +144,53 @@ async def stop(pw): assert "STOPPED" in result.stdout +def test_cancelled_playwright_start_does_not_hang(tmp_path: Path) -> None: + # Regression test for https://github.com/microsoft/playwright/issues/42296. + # Cancelling __aenter__ used to leave the driver process and the transport + # tasks running; at loop shutdown the init task absorbed its cancellation + # in Channel._abort() waiting for a reply that could never arrive, so + # asyncio.run() never returned. + script = tmp_path / "cancel_start.py" + script.write_text( + textwrap.dedent( + """ + import asyncio + + from playwright.async_api import async_playwright + + + async def run_playwright(): + async with async_playwright(): + pass + + + async def main(delay): + task = asyncio.create_task(run_playwright()) + await asyncio.sleep(delay) + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + + for delay in (0.001, 0.01, 0.05, 0.1, 0.5): + asyncio.run(main(delay)) + print("DONE", flush=True) + """ + ) + ) + result = subprocess.run( + [sys.executable, str(script)], + capture_output=True, + text=True, + timeout=60, + ) + assert result.returncode == 0, result.stderr + assert "DONE" in result.stdout + assert "Future exception was never retrieved" not in result.stderr + + async def test_should_return_proper_api_name_on_error(page: Page) -> None: try: await page.evaluate("does_not_exist") From cb622058852c637f456b2823eab62faa144a30a4 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Tue, 18 Aug 2026 16:47:58 -0700 Subject: [PATCH 2/3] chore(asyncio): simplify cancelled-startup teardown - reuse __aexit__ for teardown when __aenter__ fails or is cancelled, mirroring the sync context manager - move _stopped_future and wait_until_stopped() into the Transport base, deleting both subclass copies - reuse request_stop() when connect() finishes after a stop request - skip sending __abort__ over a closed connection --- playwright/_impl/_connection.py | 29 ++++++++++++------------ playwright/_impl/_json_pipe.py | 5 ---- playwright/_impl/_transport.py | 15 ++++-------- playwright/async_api/_context_manager.py | 13 +++++------ tests/async/test_asyncio.py | 2 +- 5 files changed, 26 insertions(+), 38 deletions(-) diff --git a/playwright/_impl/_connection.py b/playwright/_impl/_connection.py index ae65bc2ac..7887032cd 100644 --- a/playwright/_impl/_connection.py +++ b/playwright/_impl/_connection.py @@ -461,16 +461,17 @@ def _send_message_to_server( async def _abort( self, object: ChannelOwner, callback: ProtocolCallback, reason: str ) -> None: - try: - self._transport.send( - { - "guid": object._guid, - "method": "__abort__", - "params": {"id": callback.id, "reason": reason}, - } - ) - except (Error, OSError): - pass + if not self._closed_error: + try: + self._transport.send( + { + "guid": object._guid, + "method": "__abort__", + "params": {"id": callback.id, "reason": reason}, + } + ) + except (Error, OSError): + pass try: await asyncio.wait( { @@ -486,11 +487,9 @@ async def _abort( # retrieved' errors. if not callback.future.done(): callback.future.cancel() - elif not callback.future.cancelled(): - callback.future.exception() - error_future = self._transport.on_error_future - if error_future.done() and not error_future.cancelled(): - error_future.exception() + for future in (callback.future, self._transport.on_error_future): + if future.done() and not future.cancelled(): + future.exception() def dispatch(self, msg: ParsedMessagePayload) -> None: if self._closed_error: diff --git a/playwright/_impl/_json_pipe.py b/playwright/_impl/_json_pipe.py index 41973b8c7..5ace4001e 100644 --- a/playwright/_impl/_json_pipe.py +++ b/playwright/_impl/_json_pipe.py @@ -42,12 +42,7 @@ def dispose(self) -> None: self.on_error_future.cancel() self._stopped_future.cancel() - async def wait_until_stopped(self) -> None: - await self._stopped_future - async def connect(self) -> None: - self._stopped_future: asyncio.Future = asyncio.Future() - def handle_message(message: Dict) -> None: if self._stop_requested: return diff --git a/playwright/_impl/_transport.py b/playwright/_impl/_transport.py index 7aa8803ed..a35c8a27e 100644 --- a/playwright/_impl/_transport.py +++ b/playwright/_impl/_transport.py @@ -50,6 +50,7 @@ def __init__(self, loop: asyncio.AbstractEventLoop) -> None: self._loop = loop self.on_message: Callable[[ParsedMessagePayload], None] = lambda _: None self.on_error_future: asyncio.Future = loop.create_future() + self._stopped_future: asyncio.Future = loop.create_future() @abstractmethod def request_stop(self) -> None: @@ -58,9 +59,8 @@ def request_stop(self) -> None: def dispose(self) -> None: pass - @abstractmethod async def wait_until_stopped(self) -> None: - pass + await self._stopped_future @abstractmethod async def connect(self) -> None: @@ -93,7 +93,6 @@ def __init__(self, loop: asyncio.AbstractEventLoop) -> None: super().__init__(loop) self._stopped = False self._output: Optional[asyncio.StreamWriter] = None - self._stopped_future: asyncio.Future = loop.create_future() def request_stop(self) -> None: self._stopped = True @@ -102,9 +101,6 @@ def request_stop(self) -> None: if self._output: self._output.close() - async def wait_until_stopped(self) -> None: - await self._stopped_future - async def connect(self) -> None: try: # For pyinstaller and Nuitka @@ -131,14 +127,13 @@ async def connect(self) -> None: startupinfo=startupinfo, ) except Exception as exc: - if not self._stopped_future.done(): - self._stopped_future.set_result(None) + self._stopped_future.set_result(None) self.on_error_future.set_exception(exc) raise exc self._output = self._proc.stdin - if self._stopped and self._output: - self._output.close() + if self._stopped: + self.request_stop() async def run(self) -> None: assert self._proc.stdout diff --git a/playwright/async_api/_context_manager.py b/playwright/async_api/_context_manager.py index 7e807632e..adfe34f86 100644 --- a/playwright/async_api/_context_manager.py +++ b/playwright/async_api/_context_manager.py @@ -42,16 +42,15 @@ async def __aenter__(self) -> AsyncPlaywright: {self._connection._transport.on_error_future, playwright_future}, return_when=asyncio.FIRST_COMPLETED, ) - except asyncio.CancelledError: - # Cancelled while connecting - stop the driver process and the - # background tasks, otherwise they keep running with no owner. if not playwright_future.done(): playwright_future.cancel() - await self._connection.stop_async() - raise - if not playwright_future.done(): + playwright = AsyncPlaywright(next(iter(done)).result()) + except BaseException: + # Startup failed or was cancelled - stop the driver process and the + # background tasks, otherwise they keep running with no owner. playwright_future.cancel() - playwright = AsyncPlaywright(next(iter(done)).result()) + await self.__aexit__() + raise playwright.stop = self.__aexit__ # type: ignore return playwright diff --git a/tests/async/test_asyncio.py b/tests/async/test_asyncio.py index a346189e8..f9571e1b1 100644 --- a/tests/async/test_asyncio.py +++ b/tests/async/test_asyncio.py @@ -174,7 +174,7 @@ async def main(delay): pass - for delay in (0.001, 0.01, 0.05, 0.1, 0.5): + for delay in (0.001, 0.05, 0.5): asyncio.run(main(delay)) print("DONE", flush=True) """ From 9a74c93a1e2ca120b278ea50b1207da619bf66af Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Tue, 18 Aug 2026 17:30:06 -0700 Subject: [PATCH 3/3] chore: shorten comments --- playwright/_impl/_connection.py | 11 +++-------- playwright/_impl/_transport.py | 3 +-- playwright/async_api/_context_manager.py | 2 -- tests/async/test_asyncio.py | 6 ++---- 4 files changed, 6 insertions(+), 16 deletions(-) diff --git a/playwright/_impl/_connection.py b/playwright/_impl/_connection.py index 7887032cd..4b91730f1 100644 --- a/playwright/_impl/_connection.py +++ b/playwright/_impl/_connection.py @@ -356,10 +356,8 @@ async def init() -> None: try: await self._transport.run() except asyncio.CancelledError: - # This task is cancelled by asyncio.run() at loop shutdown. Release - # any tasks waiting on protocol replies that will never arrive, - # otherwise their cancellation is absorbed by Channel._abort() - # waiting for the reply and the loop never finishes closing. + # Cancelled at loop shutdown - release tasks waiting on replies + # that will never arrive. if not self._closed_error: self.cleanup() raise @@ -481,10 +479,7 @@ async def _abort( return_when=asyncio.FIRST_COMPLETED, ) finally: - # Retrieve exceptions in a finally so that this happens even if the - # wait above is interrupted by another cancellation (e.g. when the - # loop is being shut down), avoiding 'Future exception was never - # retrieved' errors. + # Retrieve exceptions even if the wait itself was cancelled. if not callback.future.done(): callback.future.cancel() for future in (callback.future, self._transport.on_error_future): diff --git a/playwright/_impl/_transport.py b/playwright/_impl/_transport.py index a35c8a27e..136ccddbd 100644 --- a/playwright/_impl/_transport.py +++ b/playwright/_impl/_transport.py @@ -96,8 +96,7 @@ def __init__(self, loop: asyncio.AbstractEventLoop) -> None: def request_stop(self) -> None: self._stopped = True - # The stop can be requested while connect() is still spawning the - # driver; connect() will close the pipe once it is available. + # May be called before connect() has spawned the driver. if self._output: self._output.close() diff --git a/playwright/async_api/_context_manager.py b/playwright/async_api/_context_manager.py index adfe34f86..e86f225ac 100644 --- a/playwright/async_api/_context_manager.py +++ b/playwright/async_api/_context_manager.py @@ -46,8 +46,6 @@ async def __aenter__(self) -> AsyncPlaywright: playwright_future.cancel() playwright = AsyncPlaywright(next(iter(done)).result()) except BaseException: - # Startup failed or was cancelled - stop the driver process and the - # background tasks, otherwise they keep running with no owner. playwright_future.cancel() await self.__aexit__() raise diff --git a/tests/async/test_asyncio.py b/tests/async/test_asyncio.py index f9571e1b1..9133b5875 100644 --- a/tests/async/test_asyncio.py +++ b/tests/async/test_asyncio.py @@ -146,10 +146,8 @@ async def stop(pw): def test_cancelled_playwright_start_does_not_hang(tmp_path: Path) -> None: # Regression test for https://github.com/microsoft/playwright/issues/42296. - # Cancelling __aenter__ used to leave the driver process and the transport - # tasks running; at loop shutdown the init task absorbed its cancellation - # in Channel._abort() waiting for a reply that could never arrive, so - # asyncio.run() never returned. + # Cancelling __aenter__ left the driver and the transport tasks running, + # and asyncio.run() hung at loop shutdown. script = tmp_path / "cancel_start.py" script.write_text( textwrap.dedent(