Skip to content

Commit df91ddb

Browse files
committed
fix(sync): survive an exception raised by a signal handler during a sync call
Fixes: #3222
1 parent dee07ae commit df91ddb

5 files changed

Lines changed: 130 additions & 30 deletions

File tree

‎playwright/_impl/_disposable.py‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,6 @@
1515
import traceback
1616
from typing import Any, Awaitable, Callable, Coroutine, Dict
1717

18-
import greenlet
19-
2018
from playwright._impl._connection import ChannelOwner, _capture_stack_trace
2119
from playwright._impl._errors import Error, is_target_closed_error
2220
from playwright._impl._greenlets import connection_closed_error, wait_for_future
@@ -78,15 +76,13 @@ def _sync(self, coro: Coroutine[Any, Any, Any]) -> object:
7876
if self._dispatcher_fiber.dead:
7977
coro.close()
8078
raise connection_closed_error()
81-
g_self = greenlet.getcurrent()
8279
task = self._loop.create_task(coro)
8380
setattr(
8481
task,
8582
"__pw_stack__",
8683
_capture_stack_trace(),
8784
)
8885
setattr(task, "__pw_stack_trace__", traceback.extract_stack(limit=10))
89-
task.add_done_callback(lambda _: g_self.switch())
9086
wait_for_future(self._loop, self._dispatcher_fiber, task)
9187
return task.result()
9288

‎playwright/_impl/_greenlets.py‎

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,33 @@ def wait_for_future(
6262
future: "asyncio.Future",
6363
) -> None:
6464
__tracebackhide__ = True
65-
while not future.done():
66-
# The dispatcher fiber exits once the connection to the driver ends, e.g.
67-
# when the driver process dies. Nothing can settle the future after that,
68-
# and switching to a dead greenlet returns right away, so we would spin.
69-
if dispatcher_fiber.dead:
70-
future.cancel()
71-
raise connection_closed_error()
72-
dispatcher_fiber.switch()
73-
# The loop is only running for as long as the dispatcher fiber is alive.
74-
if not dispatcher_fiber.dead:
75-
asyncio._set_running_loop(loop)
65+
g_self = greenlet.getcurrent()
66+
67+
def resume(_: "asyncio.Future") -> None:
68+
g_self.switch()
69+
70+
# The wait owns the callback that resumes this greenlet, so that it can
71+
# remove it again when the wait is abandoned. A callback left behind would
72+
# resume this greenlet at an arbitrary later point.
73+
future.add_done_callback(resume)
74+
try:
75+
while not future.done():
76+
# The dispatcher fiber exits once the connection to the driver ends,
77+
# e.g. when the driver process dies. Nothing can settle the future
78+
# after that, and switching to a dead greenlet returns right away,
79+
# so we would spin.
80+
if dispatcher_fiber.dead:
81+
raise connection_closed_error()
82+
# Raises when a signal handler interrupted the event loop while it
83+
# waited for the driver, see greenlet_main in _context_manager.py.
84+
dispatcher_fiber.switch()
85+
except BaseException:
86+
future.remove_done_callback(resume)
87+
future.cancel()
88+
raise
89+
finally:
90+
# The loop is only running for as long as the dispatcher fiber is alive.
91+
# Also after a signal handler interrupted it: run_forever() cleared this
92+
# on its way out, but the dispatcher is going to run the loop again.
93+
if not dispatcher_fiber.dead:
94+
asyncio._set_running_loop(loop)

‎playwright/_impl/_sync_base.py‎

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,6 @@
2929
cast,
3030
)
3131

32-
import greenlet
33-
3432
from playwright._impl._connection import _capture_stack_trace
3533
from playwright._impl._greenlets import connection_closed_error, wait_for_future
3634
from playwright._impl._helper import Error
@@ -50,12 +48,6 @@ def __init__(self, sync_base: "SyncBase", future: "asyncio.Future[T]") -> None:
5048

5149
@property
5250
def value(self) -> T:
53-
# Only wake the greenlet that is actually waiting. Registering this on
54-
# construction would also fire on cancellation and resume the creating
55-
# greenlet wherever it happens to be suspended.
56-
if not self._future.done():
57-
g_self = greenlet.getcurrent()
58-
self._future.add_done_callback(lambda _: g_self.switch())
5951
wait_for_future(
6052
self._sync_base._loop, self._sync_base._dispatcher_fiber, self._future
6153
)
@@ -111,12 +103,10 @@ def _sync(
111103
coro.close()
112104
raise connection_closed_error()
113105

114-
g_self = greenlet.getcurrent()
115106
task: asyncio.tasks.Task[Any] = self._loop.create_task(coro)
116107
setattr(task, "__pw_stack__", _capture_stack_trace())
117108
setattr(task, "__pw_stack_trace__", traceback.extract_stack(limit=10))
118109

119-
task.add_done_callback(lambda _: g_self.switch())
120110
wait_for_future(self._loop, self._dispatcher_fiber, task)
121111
return task.result()
122112

‎playwright/sync_api/_context_manager.py‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import asyncio
1616
from typing import TYPE_CHECKING, Any, Optional
1717

18-
from greenlet import greenlet
18+
from greenlet import GreenletExit, greenlet
1919

2020
from playwright._impl._connection import Connection
2121
from playwright._impl._errors import Error
@@ -48,11 +48,33 @@ def __enter__(self) -> SyncPlaywright:
4848
Please use the Async API instead."""
4949
)
5050

51+
g_self = greenlet.getcurrent()
52+
5153
# Create a new fiber for the protocol dispatcher. It will be pumping events
5254
# until the end of times. We will pass control to that fiber every time we
5355
# block while waiting for a response.
5456
def greenlet_main() -> None:
55-
self._loop.run_until_complete(self._connection.run_as_sync())
57+
task = self._loop.create_task(self._connection.run_as_sync())
58+
while not task.done():
59+
try:
60+
self._loop.run_until_complete(task)
61+
except GreenletExit:
62+
raise
63+
except BaseException as exc:
64+
if task.done():
65+
raise
66+
# asyncio contains exceptions raised in tasks and callbacks.
67+
# An exception that escapes the loop while the connection
68+
# still runs comes from a signal handler, e.g. pytest-timeout
69+
# or Ctrl+C, that fired while the loop waited for the driver.
70+
# The user code is suspended in a sync call in the greenlet
71+
# that created us: raise it there, and stay alive. The loop
72+
# and its tasks are intact, so the next sync call resumes
73+
# them.
74+
g_self.throw(exc)
75+
# Between sync calls the user code marks the loop as running,
76+
# see wait_for_future. run_forever() refuses to start then.
77+
asyncio._set_running_loop(None)
5678

5779
dispatcher_fiber = MainGreenlet(greenlet_main)
5880

@@ -63,8 +85,6 @@ def greenlet_main() -> None:
6385
self._loop,
6486
)
6587

66-
g_self = greenlet.getcurrent()
67-
6888
# Wait until initialize completes (not just Playwright __create__), matching async.
6989
self._connection.playwright_future.add_done_callback(lambda _: g_self.switch())
7090
dispatcher_fiber.switch()

‎tests/sync/test_sync.py‎

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,25 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import contextlib
1516
import multiprocessing
1617
import os
18+
import signal
1719
import subprocess
1820
import sys
1921
import textwrap
22+
import time
2023
from datetime import timedelta
2124
from pathlib import Path
22-
from typing import Any, Callable, Dict, List
25+
from typing import Any, Callable, Dict, Generator, List
2326

2427
import pytest
2528
from greenlet import greenlet
2629

2730
from playwright.sync_api import (
2831
Browser,
2932
BrowserContext,
33+
ConsoleMessage,
3034
Dialog,
3135
Error,
3236
Page,
@@ -471,6 +475,77 @@ def dispose_route():
471475
assert result.returncode == 0, result.stderr
472476

473477

478+
class Interrupted(BaseException):
479+
"""Raised from a signal handler, like pytest-timeout's Failed."""
480+
481+
482+
@contextlib.contextmanager
483+
def interrupt_after(seconds: float) -> Generator[None, None, None]:
484+
# Python runs the handler in whatever code executes when the alarm fires.
485+
# During a blocking sync call that is the dispatcher's event loop, not the
486+
# test. The previous handler and timer belong to pytest-timeout.
487+
def handler(signum: int, frame: Any) -> None:
488+
raise Interrupted()
489+
490+
previous_handler = signal.signal(signal.SIGALRM, handler)
491+
previous_timer = signal.setitimer(signal.ITIMER_REAL, seconds)
492+
try:
493+
yield
494+
finally:
495+
signal.setitimer(signal.ITIMER_REAL, *previous_timer)
496+
signal.signal(signal.SIGALRM, previous_handler)
497+
498+
499+
@pytest.mark.skipif(sys.platform == "win32", reason="SIGALRM is not available")
500+
def test_sync_should_survive_exception_raised_by_signal_handler(page: Page) -> None:
501+
with interrupt_after(0.2):
502+
with pytest.raises(Interrupted):
503+
page.wait_for_timeout(10_000)
504+
# The connection is still usable, and the next call does not wait for the
505+
# abandoned one to finish.
506+
start = time.monotonic()
507+
assert page.evaluate("1 + 1") == 2
508+
assert time.monotonic() - start < 5
509+
assert page.title() == ""
510+
511+
512+
@pytest.mark.skipif(sys.platform == "win32", reason="SIGALRM is not available")
513+
def test_sync_expect_event_should_survive_exception_raised_by_signal_handler(
514+
page: Page,
515+
) -> None:
516+
with interrupt_after(0.2):
517+
with pytest.raises(Interrupted):
518+
with page.expect_event("console"):
519+
pass
520+
with page.expect_console_message() as console_info:
521+
page.evaluate("console.log('hello')")
522+
assert console_info.value.text == "hello"
523+
524+
525+
@pytest.mark.skipif(sys.platform == "win32", reason="SIGALRM is not available")
526+
def test_sync_should_survive_exception_raised_by_signal_handler_while_event_handler_blocks(
527+
page: Page,
528+
) -> None:
529+
handled: List[str] = []
530+
531+
def handler(message: ConsoleMessage) -> None:
532+
page.wait_for_timeout(500)
533+
handled.append(message.text)
534+
535+
page.on("console", handler)
536+
with interrupt_after(0.2):
537+
with pytest.raises(Interrupted):
538+
page.evaluate("console.log('hello')")
539+
page.wait_for_timeout(10_000)
540+
assert page.evaluate("1 + 1") == 2
541+
# The handler was suspended in its own sync call. It finishes once the
542+
# dispatcher runs the event loop again.
543+
deadline = time.monotonic() + 10
544+
while not handled and time.monotonic() < deadline:
545+
page.wait_for_timeout(100)
546+
assert handled == ["hello"]
547+
548+
474549
def test_click_should_accept_timedelta_for_timeout(page: Page) -> None:
475550
with pytest.raises(TimeoutError, match="Timeout 1ms exceeded"):
476551
page.click("does-not-exist", timeout=timedelta(milliseconds=1))

0 commit comments

Comments
 (0)