|  | 
| 13 | 13 | # limitations under the License. | 
| 14 | 14 | 
 | 
| 15 | 15 | import gc | 
|  | 16 | +import os | 
|  | 17 | +import tempfile | 
| 16 | 18 | from collections import defaultdict | 
| 17 | 19 | from typing import Any | 
| 18 | 20 | 
 | 
| @@ -71,3 +73,62 @@ def handle_network_response_received(event: Any) -> None: | 
| 71 | 73 |     assert "Dialog" not in pw_objects | 
| 72 | 74 |     assert "Request" not in pw_objects | 
| 73 | 75 |     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