Skip to content

Commit bc1e717

Browse files
authored
chore: collect stale handles from the server side (#2111)
1 parent 48f4e5b commit bc1e717

File tree

3 files changed

+59
-16
lines changed

3 files changed

+59
-16
lines changed

playwright/_impl/_connection.py

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,11 @@
5151

5252

5353
class Channel(AsyncIOEventEmitter):
54-
def __init__(self, connection: "Connection", guid: str) -> None:
54+
def __init__(self, connection: "Connection", object: "ChannelOwner") -> None:
5555
super().__init__()
56-
self._connection: Connection = connection
57-
self._guid = guid
58-
self._object: Optional[ChannelOwner] = None
56+
self._connection = connection
57+
self._guid = object._guid
58+
self._object = object
5959

6060
async def send(self, method: str, params: Dict = None) -> Any:
6161
return await self._connection.wrap_api_call(
@@ -71,7 +71,7 @@ def send_no_reply(self, method: str, params: Dict = None) -> None:
7171
# No reply messages are used to e.g. waitForEventInfo(after).
7272
self._connection.wrap_api_call_sync(
7373
lambda: self._connection._send_message_to_server(
74-
self._guid, method, {} if params is None else params, True
74+
self._object, method, {} if params is None else params, True
7575
)
7676
)
7777

@@ -80,7 +80,9 @@ async def inner_send(
8080
) -> Any:
8181
if params is None:
8282
params = {}
83-
callback = self._connection._send_message_to_server(self._guid, method, params)
83+
callback = self._connection._send_message_to_server(
84+
self._object, method, params
85+
)
8486
if self._connection._error:
8587
error = self._connection._error
8688
self._connection._error = None
@@ -121,33 +123,34 @@ def __init__(
121123
self._loop: asyncio.AbstractEventLoop = parent._loop
122124
self._dispatcher_fiber: Any = parent._dispatcher_fiber
123125
self._type = type
124-
self._guid = guid
126+
self._guid: str = guid
125127
self._connection: Connection = (
126128
parent._connection if isinstance(parent, ChannelOwner) else parent
127129
)
128130
self._parent: Optional[ChannelOwner] = (
129131
parent if isinstance(parent, ChannelOwner) else None
130132
)
131133
self._objects: Dict[str, "ChannelOwner"] = {}
132-
self._channel: Channel = Channel(self._connection, guid)
133-
self._channel._object = self
134+
self._channel: Channel = Channel(self._connection, self)
134135
self._initializer = initializer
136+
self._was_collected = False
135137

136138
self._connection._objects[guid] = self
137139
if self._parent:
138140
self._parent._objects[guid] = self
139141

140142
self._event_to_subscription_mapping: Dict[str, str] = {}
141143

142-
def _dispose(self) -> None:
144+
def _dispose(self, reason: Optional[str]) -> None:
143145
# Clean up from parent and connection.
144146
if self._parent:
145147
del self._parent._objects[self._guid]
146148
del self._connection._objects[self._guid]
149+
self._was_collected = reason == "gc"
147150

148151
# Dispose all children.
149152
for object in list(self._objects.values()):
150-
object._dispose()
153+
object._dispose(reason)
151154
self._objects.clear()
152155

153156
def _adopt(self, child: "ChannelOwner") -> None:
@@ -308,10 +311,14 @@ def set_in_tracing(self, is_tracing: bool) -> None:
308311
self._tracing_count -= 1
309312

310313
def _send_message_to_server(
311-
self, guid: str, method: str, params: Dict, no_reply: bool = False
314+
self, object: ChannelOwner, method: str, params: Dict, no_reply: bool = False
312315
) -> ProtocolCallback:
313316
if self._closed_error_message:
314317
raise Error(self._closed_error_message)
318+
if object._was_collected:
319+
raise Error(
320+
"The object has been collected to prevent unbounded heap growth."
321+
)
315322
self._last_id += 1
316323
id = self._last_id
317324
callback = ProtocolCallback(self._loop)
@@ -335,7 +342,7 @@ def _send_message_to_server(
335342
)
336343
message = {
337344
"id": id,
338-
"guid": guid,
345+
"guid": object._guid,
339346
"method": method,
340347
"params": self._replace_channels_with_guids(params),
341348
"metadata": {
@@ -345,7 +352,7 @@ def _send_message_to_server(
345352
"internal": not stack_trace_information["apiName"],
346353
},
347354
}
348-
if self._tracing_count > 0 and frames and guid != "localUtils":
355+
if self._tracing_count > 0 and frames and object._guid != "localUtils":
349356
self.local_utils.add_stack_to_tracing_no_reply(id, frames)
350357

351358
self._transport.send(message)
@@ -401,7 +408,8 @@ def dispatch(self, msg: ParsedMessagePayload) -> None:
401408
return
402409

403410
if method == "__dispose__":
404-
self._objects[guid]._dispose()
411+
assert isinstance(params, dict)
412+
self._objects[guid]._dispose(cast(Optional[str], params.get("reason")))
405413
return
406414
object = self._objects[guid]
407415
should_replace_guids_with_channels = "jsonPipe@" not in guid

tests/async/test_asyncio.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
import pytest
1919

20-
from playwright.async_api import async_playwright
20+
from playwright.async_api import Page, async_playwright
2121

2222
from ..server import Server
2323

@@ -67,3 +67,20 @@ async def test_cancel_pending_protocol_call_on_playwright_stop(server: Server) -
6767
with pytest.raises(Exception) as exc_info:
6868
await pending_task
6969
assert "Connection closed" in str(exc_info.value)
70+
71+
72+
async def test_should_collect_stale_handles(page: Page, server: Server) -> None:
73+
page.on("request", lambda: None)
74+
response = await page.goto(server.PREFIX + "/title.html")
75+
for i in range(1000):
76+
await page.evaluate(
77+
"""async () => {
78+
const response = await fetch('/');
79+
await response.text();
80+
}"""
81+
)
82+
with pytest.raises(Exception) as exc_info:
83+
await response.all_headers()
84+
assert "The object has been collected to prevent unbounded heap growth." in str(
85+
exc_info.value
86+
)

tests/sync/test_sync.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,3 +333,21 @@ def test_call_sync_method_after_playwright_close_with_own_loop(
333333
p.start()
334334
p.join()
335335
assert p.exitcode == 0
336+
337+
338+
def test_should_collect_stale_handles(page: Page, server: Server) -> None:
339+
page.on("request", lambda request: None)
340+
response = page.goto(server.PREFIX + "/title.html")
341+
assert response
342+
for i in range(1000):
343+
page.evaluate(
344+
"""async () => {
345+
const response = await fetch('/');
346+
await response.text();
347+
}"""
348+
)
349+
with pytest.raises(Exception) as exc_info:
350+
response.all_headers()
351+
assert "The object has been collected to prevent unbounded heap growth." in str(
352+
exc_info.value
353+
)

0 commit comments

Comments
 (0)