Skip to content

Commit b913f48

Browse files
authored
fix: cancel protocol calls on connection / driver close (microsoft#1897)
1 parent 19cf427 commit b913f48

8 files changed

+57
-28
lines changed

playwright/_impl/_browser.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from playwright._impl._cdp_session import CDPSession
3030
from playwright._impl._connection import ChannelOwner, from_channel
3131
from playwright._impl._helper import (
32+
BROWSER_CLOSED_ERROR,
3233
ColorScheme,
3334
ForcedColors,
3435
HarContentPolicy,
@@ -182,7 +183,7 @@ async def close(self) -> None:
182183
if not is_safe_close_error(e):
183184
raise e
184185
if self._should_close_connection_on_close:
185-
await self._connection.stop_async()
186+
await self._connection.stop_async(BROWSER_CLOSED_ERROR)
186187

187188
@property
188189
def version(self) -> str:

playwright/_impl/_browser_type.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from_nullable_channel,
3434
)
3535
from playwright._impl._helper import (
36+
BROWSER_CLOSED_ERROR,
3637
ColorScheme,
3738
Env,
3839
ForcedColors,
@@ -218,7 +219,7 @@ async def connect(
218219

219220
timeout_future = throw_on_timeout(timeout, Error("Connection timed out"))
220221
done, pending = await asyncio.wait(
221-
{transport.on_error_future, playwright_future, timeout_future},
222+
{playwright_future, timeout_future},
222223
return_when=asyncio.FIRST_COMPLETED,
223224
)
224225
if not playwright_future.done():
@@ -234,13 +235,13 @@ async def connect(
234235
self._did_launch_browser(browser)
235236
browser._should_close_connection_on_close = True
236237

237-
def handle_transport_close() -> None:
238+
def handle_transport_close(transport_exception: str) -> None:
238239
for context in browser.contexts:
239240
for page in context.pages:
240241
page._on_close()
241242
context._on_close()
242243
browser._on_close()
243-
connection.cleanup()
244+
connection.cleanup(transport_exception or BROWSER_CLOSED_ERROR)
244245

245246
transport.once("close", handle_transport_close)
246247

playwright/_impl/_connection.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
from pyee.asyncio import AsyncIOEventEmitter
3737

3838
import playwright
39-
from playwright._impl._helper import ParsedMessagePayload, parse_error
39+
from playwright._impl._helper import Error, ParsedMessagePayload, parse_error
4040
from playwright._impl._transport import Transport
4141

4242
if TYPE_CHECKING:
@@ -242,6 +242,7 @@ def __init__(
242242
] = contextvars.ContextVar("ApiZone", default=None)
243243
self._local_utils: Optional["LocalUtils"] = local_utils
244244
self._tracing_count = 0
245+
self._closed_error_message: Optional[str] = None
245246

246247
@property
247248
def local_utils(self) -> "LocalUtils":
@@ -272,16 +273,24 @@ def stop_sync(self) -> None:
272273
self._loop.run_until_complete(self._transport.wait_until_stopped())
273274
self.cleanup()
274275

275-
async def stop_async(self) -> None:
276+
async def stop_async(self, error_message: str = None) -> None:
276277
self._transport.request_stop()
277278
await self._transport.wait_until_stopped()
278-
self.cleanup()
279+
self.cleanup(error_message)
279280

280-
def cleanup(self) -> None:
281+
def cleanup(self, error_message: str = None) -> None:
282+
if not error_message:
283+
error_message = "Connection closed"
284+
self._closed_error_message = error_message
281285
if self._init_task and not self._init_task.done():
282286
self._init_task.cancel()
283287
for ws_connection in self._child_ws_connections:
284288
ws_connection._transport.dispose()
289+
for callback in self._callbacks.values():
290+
callback.future.set_exception(Error(error_message))
291+
# Prevent 'Task exception was never retrieved'
292+
callback.future.exception()
293+
self._callbacks.clear()
285294
self.emit("close")
286295

287296
def call_on_object_with_known_name(
@@ -298,6 +307,8 @@ def set_in_tracing(self, is_tracing: bool) -> None:
298307
def _send_message_to_server(
299308
self, guid: str, method: str, params: Dict
300309
) -> ProtocolCallback:
310+
if self._closed_error_message:
311+
raise Error(self._closed_error_message)
301312
self._last_id += 1
302313
id = self._last_id
303314
callback = ProtocolCallback(self._loop)
@@ -339,6 +350,8 @@ def _send_message_to_server(
339350
return callback
340351

341352
def dispatch(self, msg: ParsedMessagePayload) -> None:
353+
if self._closed_error_message:
354+
return
342355
id = msg.get("id")
343356
if id:
344357
callback = self._callbacks.pop(id)

playwright/_impl/_helper.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -314,10 +314,14 @@ def prepare_interception_patterns(
314314
return patterns
315315

316316

317+
BROWSER_CLOSED_ERROR = "Browser has been closed"
318+
BROWSER_OR_CONTEXT_CLOSED_ERROR = "Target page, context or browser has been closed"
319+
320+
317321
def is_safe_close_error(error: Exception) -> bool:
318322
message = str(error)
319-
return message.endswith("Browser has been closed") or message.endswith(
320-
"Target page, context or browser has been closed"
323+
return message.endswith(BROWSER_CLOSED_ERROR) or message.endswith(
324+
BROWSER_OR_CONTEXT_CLOSED_ERROR
321325
)
322326

323327

playwright/_impl/_json_pipe.py

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,8 @@
1717

1818
from pyee.asyncio import AsyncIOEventEmitter
1919

20-
from playwright._impl._api_types import Error
2120
from playwright._impl._connection import Channel
22-
from playwright._impl._helper import ParsedMessagePayload, parse_error
21+
from playwright._impl._helper import ParsedMessagePayload
2322
from playwright._impl._transport import Transport
2423

2524

@@ -37,7 +36,7 @@ def __init__(
3736

3837
def request_stop(self) -> None:
3938
self._stop_requested = True
40-
self._loop.create_task(self._pipe_channel.send("close", {}))
39+
self._pipe_channel.send_no_reply("close", {})
4140

4241
def dispose(self) -> None:
4342
self.on_error_future.cancel()
@@ -49,17 +48,17 @@ async def wait_until_stopped(self) -> None:
4948
async def connect(self) -> None:
5049
self._stopped_future: asyncio.Future = asyncio.Future()
5150

51+
close_error: Optional[str] = None
52+
5253
def handle_message(message: Dict) -> None:
53-
if not self._stop_requested:
54+
try:
5455
self.on_message(cast(ParsedMessagePayload, message))
56+
except Exception as e:
57+
nonlocal close_error
58+
close_error = str(e)
5559

56-
def handle_closed(error: Optional[Dict]) -> None:
57-
self.emit("close")
58-
self.on_error_future.set_exception(
59-
parse_error(error["error"])
60-
if error
61-
else Error("Playwright connection closed")
62-
)
60+
def handle_closed() -> None:
61+
self.emit("close", close_error)
6362
self._stopped_future.set_result(None)
6463

6564
self._pipe_channel.on(
@@ -68,13 +67,11 @@ def handle_closed(error: Optional[Dict]) -> None:
6867
)
6968
self._pipe_channel.on(
7069
"closed",
71-
lambda params: handle_closed(params.get("error")),
70+
lambda _: handle_closed(),
7271
)
7372

7473
async def run(self) -> None:
7574
await self._stopped_future
7675

7776
def send(self, message: Dict) -> None:
78-
if self._stop_requested:
79-
raise Error("Playwright connection closed")
80-
self._loop.create_task(self._pipe_channel.send("send", {"message": message}))
77+
self._pipe_channel.send_no_reply("send", {"message": message})

tests/async/test_asyncio.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919

2020
from playwright.async_api import async_playwright
2121

22+
from ..server import Server
23+
2224

2325
async def test_should_cancel_underlying_protocol_calls(
2426
browser_name: str, launch_arguments: Dict
@@ -54,3 +56,14 @@ async def test_async_playwright_stop_multiple_times() -> None:
5456
playwright = await async_playwright().start()
5557
await playwright.stop()
5658
await playwright.stop()
59+
60+
61+
async def test_cancel_pending_protocol_call_on_playwright_stop(server: Server) -> None:
62+
server.set_route("/hang", lambda _: None)
63+
playwright = await async_playwright().start()
64+
api_request_context = await playwright.request.new_context()
65+
pending_task = asyncio.create_task(api_request_context.get(server.PREFIX + "/hang"))
66+
await playwright.stop()
67+
with pytest.raises(Exception) as exc_info:
68+
await pending_task
69+
assert "Connection closed" in str(exc_info.value)

tests/async/test_browsertype_connect.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ async def test_browser_type_connect_should_throw_when_used_after_is_connected_re
145145

146146
with pytest.raises(Error) as exc_info:
147147
await page.evaluate("1 + 1")
148-
assert "Playwright connection closed" == exc_info.value.message
148+
assert "has been closed" in exc_info.value.message
149149
assert browser.is_connected() is False
150150

151151

@@ -159,7 +159,7 @@ async def test_browser_type_connect_should_reject_navigation_when_browser_closes
159159

160160
with pytest.raises(Error) as exc_info:
161161
await page.goto(server.PREFIX + "/one-style.html")
162-
assert "Playwright connection closed" in exc_info.value.message
162+
assert "has been closed" in exc_info.value.message
163163

164164

165165
async def test_should_not_allow_getting_the_path(

tests/sync/test_browsertype_connect.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ def test_browser_type_connect_should_throw_when_used_after_is_connected_returns_
140140

141141
with pytest.raises(Error) as exc_info:
142142
page.evaluate("1 + 1")
143-
assert "Playwright connection closed" == exc_info.value.message
143+
assert "has been closed" in exc_info.value.message
144144
assert browser.is_connected() is False
145145

146146

0 commit comments

Comments
 (0)