Skip to content

Commit 019df4b

Browse files
authored
fix: prevent ProtocolCallback memory leak during tracing (#2977) (#2980)
1 parent 4fb9e07 commit 019df4b

File tree

2 files changed

+66
-3
lines changed

2 files changed

+66
-3
lines changed

playwright/_impl/_connection.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -218,10 +218,12 @@ def remove_listener(self, event: str, f: Any) -> None:
218218

219219

220220
class ProtocolCallback:
221-
def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
221+
def __init__(self, loop: asyncio.AbstractEventLoop, no_reply: bool = False) -> None:
222222
self.stack_trace: traceback.StackSummary
223-
self.no_reply: bool
223+
self.no_reply = no_reply
224224
self.future = loop.create_future()
225+
if no_reply:
226+
self.future.set_result(None)
225227
# The outer task can get cancelled by the user, this forwards the cancellation to the inner task.
226228
current_task = asyncio.current_task()
227229

@@ -360,7 +362,7 @@ def _send_message_to_server(
360362
)
361363
self._last_id += 1
362364
id = self._last_id
363-
callback = ProtocolCallback(self._loop)
365+
callback = ProtocolCallback(self._loop, no_reply=no_reply)
364366
task = asyncio.current_task(self._loop)
365367
callback.stack_trace = cast(
366368
traceback.StackSummary,

tests/test_reference_count_async.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
# limitations under the License.
1414

1515
import gc
16+
import os
17+
import tempfile
1618
from collections import defaultdict
1719
from typing import Any
1820

@@ -71,3 +73,62 @@ def handle_network_response_received(event: Any) -> None:
7173
assert "Dialog" not in pw_objects
7274
assert "Request" not in pw_objects
7375
assert "Route" not in pw_objects
76+
77+
78+
@pytest.mark.asyncio
79+
async def test_tracing_should_not_leak_protocol_callbacks(browser_name: str) -> None:
80+
"""
81+
Regression test for https://github.com/microsoft/playwright-python/issues/2977
82+
83+
This test ensures that ProtocolCallback objects don't accumulate when tracing is enabled.
84+
The memory leak occurred because no_reply callbacks were created with circular references
85+
but never cleaned up.
86+
"""
87+
88+
def count_protocol_callbacks() -> int:
89+
"""Count ProtocolCallback objects in memory."""
90+
gc.collect()
91+
count = 0
92+
for obj in gc.get_objects():
93+
if (
94+
hasattr(obj, "__class__")
95+
and obj.__class__.__name__ == "ProtocolCallback"
96+
):
97+
count += 1
98+
return count
99+
100+
with tempfile.TemporaryDirectory() as temp_dir:
101+
trace_file = os.path.join(temp_dir, "test_trace.zip")
102+
103+
async with async_playwright() as p:
104+
browser = await p[browser_name].launch()
105+
context = await browser.new_context()
106+
107+
# Start tracing to trigger the creation of no_reply callbacks
108+
await context.tracing.start(screenshots=True, snapshots=True)
109+
110+
initial_count = count_protocol_callbacks()
111+
112+
# Perform operations that would create tracing callbacks
113+
for _ in range(3):
114+
page = await context.new_page()
115+
await page.goto("data:text/html,<h1>Test Page</h1>")
116+
await page.wait_for_load_state("networkidle")
117+
await page.evaluate(
118+
"document.querySelector('h1').textContent = 'Modified'"
119+
)
120+
await page.close()
121+
122+
# Stop tracing
123+
await context.tracing.stop(path=trace_file)
124+
await browser.close()
125+
126+
# Force garbage collection and check callback count
127+
gc.collect()
128+
final_count = count_protocol_callbacks()
129+
130+
# The key assertion: callback count should not have increased significantly
131+
# Allow for a small number of legitimate callbacks but ensure no major leak
132+
assert (
133+
final_count - initial_count <= 5
134+
), f"ProtocolCallback leak detected: initial={initial_count}, final={final_count}, leaked={final_count - initial_count}"

0 commit comments

Comments
 (0)