Skip to content

Commit ee3d72a

Browse files
the-asuraclaude
andcommitted
fix(sync): fail fast instead of spinning at 100% CPU when the dispatcher fiber dies
When the driver connection ends without going through Connection.cleanup() - e.g. the driver process dies, or a remote CDP connection wedges and the transport takes the silent IncompleteReadError path - the dispatcher fiber finishes with sync calls still pending. Their tasks can never complete, and because switching to a dead greenlet returns immediately to its parent, the waiting loop in SyncBase._sync degenerates into a pure userspace busy-loop: the thread pins one core, holds the GIL, and neither raises nor logs. Observed in production via strace (100% futex, zero IO syscalls) with py-spy showing threads stopped on the same _sync line for the process lifetime. Detect the dead dispatcher inside the waiting loop and raise TargetClosedError instead. A dead fiber can never complete the task, so raising there is always correct. The regression test recreates the failure end to end: kill the driver on the silent path, then make one more sync call. Without the fix the call spins forever (the subprocess times out); with it, it raises promptly. Co-authored-by: Claude <noreply@anthropic.com>
1 parent 010a9cc commit ee3d72a

2 files changed

Lines changed: 53 additions & 0 deletions

File tree

playwright/_impl/_sync_base.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import greenlet
3333

3434
from playwright._impl._connection import _capture_stack_trace
35+
from playwright._impl._errors import TargetClosedError
3536
from playwright._impl._helper import Error
3637
from playwright._impl._impl_to_api_mapping import ImplToApiMapping, ImplWrapper
3738

@@ -110,6 +111,17 @@ def _sync(
110111

111112
task.add_done_callback(lambda _: g_self.switch())
112113
while not task.done():
114+
if self._dispatcher_fiber.dead:
115+
# The dispatcher fiber runs the connection's event loop; when the
116+
# transport ends without going through Connection.cleanup() (e.g.
117+
# the driver process dies or a remote connection drops), the fiber
118+
# finishes with this task still pending. Switching to a dead
119+
# greenlet returns immediately to its parent - this very loop -
120+
# so without this check the thread spins at 100% CPU forever.
121+
task.cancel()
122+
raise TargetClosedError(
123+
"Playwright connection closed while this call was pending"
124+
)
113125
self._dispatcher_fiber.switch()
114126
asyncio._set_running_loop(self._loop)
115127
return task.result()

tests/sync/test_sync.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,47 @@ def test_should_not_orphan_callback_on_non_serializable_params(
399399
assert "Future exception was never retrieved" not in result.stderr
400400

401401

402+
@pytest.mark.only_browser("chromium") # browser-agnostic; run once
403+
def test_sync_call_fails_fast_when_dispatcher_dies_mid_call(tmp_path: Path) -> None:
404+
# When the driver process dies without the transport reporting an error
405+
# (its stdout hits EOF on the not-self-initiated stop path), the dispatcher
406+
# fiber finishes silently and Connection.cleanup() never runs, so a pending
407+
# sync call's task can never complete. Switching to the dead fiber returns
408+
# immediately, degenerating the _sync() waiting loop into a 100% CPU spin
409+
# that raises nothing and logs nothing. The loop must detect the dead
410+
# dispatcher and raise instead. Run in a subprocess: without the fix the
411+
# doomed call spins forever and this test would hang, not fail.
412+
script = tmp_path / "dead_dispatcher.py"
413+
script.write_text(
414+
textwrap.dedent(
415+
"""
416+
import sys
417+
418+
from playwright.sync_api import Error, sync_playwright
419+
420+
p = sync_playwright().start()
421+
transport = p._impl_obj._connection._transport
422+
# Emulate an abrupt driver death that reports no transport error:
423+
# the same shape a wedged remote connection produces in the wild.
424+
transport._stopped = True
425+
transport._proc.kill()
426+
try:
427+
p.chromium.launch()
428+
sys.exit(2) # returned normally: the driver is gone, impossible
429+
except Error:
430+
sys.exit(0) # failed fast instead of spinning
431+
"""
432+
)
433+
)
434+
result = subprocess.run(
435+
[sys.executable, str(script)],
436+
capture_output=True,
437+
text=True,
438+
timeout=30,
439+
)
440+
assert result.returncode == 0, (result.stdout, result.stderr)
441+
442+
402443
def test_click_should_accept_timedelta_for_timeout(page: Page) -> None:
403444
with pytest.raises(TimeoutError, match="Timeout 1ms exceeded"):
404445
page.click("does-not-exist", timeout=timedelta(milliseconds=1))

0 commit comments

Comments
 (0)