Skip to content

Commit 67e59f5

Browse files
authored
fix(asyncio): do not deadlock in atexit handler (#3056)
1 parent 797946e commit 67e59f5

4 files changed

Lines changed: 116 additions & 29 deletions

File tree

CLAUDE.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,40 @@ It documents the full process: the upstream commit-range diff over `docs/src/api
5959
- New public methods on impl classes need a sync test mirror under `tests/sync/`.
6060
- Keep `expected_api_mismatch.txt` minimal — every entry needs a one-line rationale comment above it.
6161
- Prefer `locals_to_params(locals())` for forwarding optional kwargs to channel sends, matching the rest of the codebase.
62+
63+
## Commit Convention
64+
65+
Before committing, run `mypy playwright` and fix errors.
66+
67+
Semantic commit messages: `label(scope): description`
68+
69+
Labels: `fix`, `feat`, `chore`, `docs`, `test`, `devops`
70+
71+
```bash
72+
git checkout -b fix-12345
73+
# ... make changes ...
74+
git add <changed-files>
75+
git commit -m "$(cat <<'EOF'
76+
fix(asyncio): do not deadlock in atexit handler
77+
78+
Fixes: https://github.com/microsoft/playwright-python/issues/12345
79+
EOF
80+
)"
81+
git push origin fix-12345
82+
gh pr create --repo microsoft/playwright-python --head username:fix-12345 \
83+
--title "fix(asyncio): do not deadlock in atexit handler" \
84+
--body "$(cat <<'EOF'
85+
## Summary
86+
- <describe the change very! briefly>
87+
88+
Fixes https://github.com/microsoft/playwright-python/issues/12345
89+
EOF
90+
)"
91+
```
92+
93+
Never add Co-Authored-By agents in commit message.
94+
Never add "Generated with" in commit message.
95+
Never add test plan to PR description. Keep PR description short — a few bullet points at most.
96+
Branch naming for issue fixes: `fix-<issue-number>`
97+
98+
**Never `git push` without an explicit instruction to push.** Applies even when a PR is already open for the branch — additional commits are immediately visible to reviewers. Commit locally, report what was committed, and wait. Only push when the user's message contains "push", "upload", "create PR", "ship it", or equivalent.

local-requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
asyncio-atexit==1.0.1
12
autobahn==23.1.2
23
black==25.1.0
34
build==1.3.0

playwright/_impl/_transport.py

Lines changed: 35 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -137,38 +137,44 @@ async def connect(self) -> None:
137137
async def run(self) -> None:
138138
assert self._proc.stdout
139139
assert self._proc.stdin
140-
while not self._stopped:
141-
try:
142-
buffer = await self._proc.stdout.readexactly(4)
143-
if self._stopped:
144-
break
145-
length = int.from_bytes(buffer, byteorder="little", signed=False)
146-
buffer = bytes(0)
147-
while length:
148-
to_read = min(length, 32768)
149-
data = await self._proc.stdout.readexactly(to_read)
140+
try:
141+
while not self._stopped:
142+
try:
143+
buffer = await self._proc.stdout.readexactly(4)
144+
if self._stopped:
145+
break
146+
length = int.from_bytes(buffer, byteorder="little", signed=False)
147+
buffer = bytes(0)
148+
while length:
149+
to_read = min(length, 32768)
150+
data = await self._proc.stdout.readexactly(to_read)
151+
if self._stopped:
152+
break
153+
length -= to_read
154+
if len(buffer):
155+
buffer = buffer + data
156+
else:
157+
buffer = data
150158
if self._stopped:
151159
break
152-
length -= to_read
153-
if len(buffer):
154-
buffer = buffer + data
155-
else:
156-
buffer = data
157-
if self._stopped:
158-
break
159160

160-
obj = self.deserialize_message(buffer)
161-
self.on_message(obj)
162-
except asyncio.IncompleteReadError:
163-
if not self._stopped:
164-
self.on_error_future.set_exception(
165-
Exception("Connection closed while reading from the driver")
166-
)
167-
break
168-
await asyncio.sleep(0)
169-
170-
await self._proc.communicate()
171-
self._stopped_future.set_result(None)
161+
obj = self.deserialize_message(buffer)
162+
self.on_message(obj)
163+
except asyncio.IncompleteReadError:
164+
if not self._stopped:
165+
self.on_error_future.set_exception(
166+
Exception("Connection closed while reading from the driver")
167+
)
168+
break
169+
await asyncio.sleep(0)
170+
171+
await self._proc.communicate()
172+
finally:
173+
# Release waiters on wait_until_stopped() even if this task was
174+
# cancelled before reaching the end (e.g. by asyncio.run()'s
175+
# task-cancellation phase that runs before asyncio-atexit hooks).
176+
if not self._stopped_future.done():
177+
self._stopped_future.set_result(None)
172178

173179
def send(self, message: Dict) -> None:
174180
assert self._output

tests/async/test_asyncio.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@
1313
# limitations under the License.
1414
import asyncio
1515
import gc
16+
import subprocess
1617
import sys
18+
import textwrap
19+
from pathlib import Path
1720
from typing import Dict
1821

1922
import pytest
@@ -89,6 +92,46 @@ async def raise_exception() -> None:
8992
assert await page.evaluate("() => 11 * 11") == 121
9093

9194

95+
def test_stop_does_not_deadlock_with_asyncio_atexit(tmp_path: Path) -> None:
96+
# Regression test for https://github.com/microsoft/playwright-python/issues/3004.
97+
# asyncio.run() cancels all remaining tasks (including transport.run()) before
98+
# calling loop.close(). asyncio-atexit hooks loop.close() to run async cleanup,
99+
# so awaiting playwright.stop() at that point used to deadlock on a future that
100+
# the (already cancelled) run task would never set.
101+
script = tmp_path / "atexit_stop.py"
102+
script.write_text(
103+
textwrap.dedent(
104+
"""
105+
import asyncio
106+
107+
import asyncio_atexit
108+
from playwright.async_api import async_playwright
109+
110+
111+
async def main():
112+
pw = await async_playwright().start()
113+
asyncio_atexit.register(lambda: stop(pw))
114+
115+
116+
async def stop(pw):
117+
await pw.stop()
118+
print("STOPPED", flush=True)
119+
120+
121+
asyncio.run(main())
122+
"""
123+
)
124+
)
125+
result = subprocess.run(
126+
[sys.executable, str(script)],
127+
capture_output=True,
128+
text=True,
129+
timeout=30,
130+
)
131+
assert result.returncode == 0, result.stderr
132+
assert "STOPPED" in result.stdout
133+
134+
92135
async def test_should_return_proper_api_name_on_error(page: Page) -> None:
93136
try:
94137
await page.evaluate("does_not_exist")

0 commit comments

Comments
 (0)