Skip to content

Commit 71eb7bc

Browse files
committed
fix: loop closed exception which got emitted in subprocess destructor
1 parent dbb6cc6 commit 71eb7bc

File tree

5 files changed

+205
-16
lines changed

5 files changed

+205
-16
lines changed

playwright/_impl/_connection.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ def __init__(
180180
self.playwright_future: asyncio.Future["Playwright"] = loop.create_future()
181181
self._error: Optional[BaseException] = None
182182
self.is_remote = False
183+
self._init_task: Optional[asyncio.Task] = None
183184

184185
def mark_as_remote(self) -> None:
185186
self.is_remote = True
@@ -196,12 +197,13 @@ async def init() -> None:
196197
self.playwright_future.set_result(await self._root_object.initialize())
197198

198199
await self._transport.connect()
199-
self._loop.create_task(init())
200+
self._init_task = self._loop.create_task(init())
200201
await self._transport.run()
201202

202203
def stop_sync(self) -> None:
203204
self._transport.request_stop()
204205
self._dispatcher_fiber.switch()
206+
self._loop.run_until_complete(self._transport.wait_until_stopped())
205207
self.cleanup()
206208

207209
async def stop_async(self) -> None:
@@ -210,6 +212,8 @@ async def stop_async(self) -> None:
210212
self.cleanup()
211213

212214
def cleanup(self) -> None:
215+
if self._init_task and not self._init_task.done():
216+
self._init_task.cancel()
213217
for ws_connection in self._child_ws_connections:
214218
ws_connection._transport.dispose()
215219
self.emit("close")
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
# PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2
2+
# --------------------------------------------
3+
#
4+
# 1. This LICENSE AGREEMENT is between the Python Software Foundation
5+
# ("PSF"), and the Individual or Organization ("Licensee") accessing and
6+
# otherwise using this software ("Python") in source or binary form and
7+
# its associated documentation.
8+
#
9+
# 2. Subject to the terms and conditions of this License Agreement, PSF hereby
10+
# grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce,
11+
# analyze, test, perform and/or display publicly, prepare derivative works,
12+
# distribute, and otherwise use Python alone or in any derivative version,
13+
# provided, however, that PSF's License Agreement and PSF's notice of copyright,
14+
# i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010,
15+
# 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020 Python Software Foundation;
16+
# All Rights Reserved" are retained in Python alone or in any derivative version
17+
# prepared by Licensee.
18+
#
19+
# 3. In the event Licensee prepares a derivative work that is based on
20+
# or incorporates Python or any part thereof, and wants to make
21+
# the derivative work available to others as provided herein, then
22+
# Licensee hereby agrees to include in any such work a brief summary of
23+
# the changes made to Python.
24+
#
25+
# 4. PSF is making Python available to Licensee on an "AS IS"
26+
# basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
27+
# IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND
28+
# DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
29+
# FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT
30+
# INFRINGE ANY THIRD PARTY RIGHTS.
31+
#
32+
# 5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON
33+
# FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS
34+
# A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON,
35+
# OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
36+
#
37+
# 6. This License Agreement will automatically terminate upon a material
38+
# breach of its terms and conditions.
39+
#
40+
# 7. Nothing in this License Agreement shall be deemed to create any
41+
# relationship of agency, partnership, or joint venture between PSF and
42+
# Licensee. This License Agreement does not grant permission to use PSF
43+
# trademarks or trade name in a trademark sense to endorse or promote
44+
# products or services of Licensee, or any third party.
45+
#
46+
# 8. By copying, installing or otherwise using Python, Licensee
47+
# agrees to be bound by the terms and conditions of this License
48+
# Agreement.
49+
#
50+
# type: ignore
51+
52+
import itertools
53+
import os
54+
import threading
55+
import warnings
56+
from asyncio import AbstractChildWatcher, events
57+
from asyncio.log import logger
58+
59+
60+
class ThreadedChildWatcher(AbstractChildWatcher):
61+
"""Threaded child watcher implementation.
62+
The watcher uses a thread per process
63+
for waiting for the process finish.
64+
It doesn't require subscription on POSIX signal
65+
but a thread creation is not free.
66+
The watcher has O(1) complexity, its performance doesn't depend
67+
on amount of spawn processes.
68+
"""
69+
70+
def __init__(self):
71+
self._pid_counter = itertools.count(0)
72+
self._threads = {}
73+
74+
def is_active(self):
75+
return True
76+
77+
def close(self):
78+
self._join_threads()
79+
80+
def _join_threads(self):
81+
"""Internal: Join all non-daemon threads"""
82+
threads = [
83+
thread
84+
for thread in list(self._threads.values())
85+
if thread.is_alive() and not thread.daemon
86+
]
87+
for thread in threads:
88+
thread.join()
89+
90+
def __enter__(self):
91+
return self
92+
93+
def __exit__(self, exc_type, exc_val, exc_tb):
94+
pass
95+
96+
def __del__(self, _warn=warnings.warn):
97+
threads = [
98+
thread for thread in list(self._threads.values()) if thread.is_alive()
99+
]
100+
if threads:
101+
_warn(
102+
f"{self.__class__} has registered but not finished child processes",
103+
ResourceWarning,
104+
source=self,
105+
)
106+
107+
def add_child_handler(self, pid, callback, *args):
108+
loop = events.get_running_loop()
109+
thread = threading.Thread(
110+
target=self._do_waitpid,
111+
name=f"waitpid-{next(self._pid_counter)}",
112+
args=(loop, pid, callback, args),
113+
daemon=True,
114+
)
115+
self._threads[pid] = thread
116+
thread.start()
117+
118+
def remove_child_handler(self, pid):
119+
# asyncio never calls remove_child_handler() !!!
120+
# The method is no-op but is implemented because
121+
# abstract base classe requires it
122+
return True
123+
124+
def attach_loop(self, loop):
125+
pass
126+
127+
def _do_waitpid(self, loop, expected_pid, callback, args):
128+
assert expected_pid > 0
129+
130+
try:
131+
pid, status = os.waitpid(expected_pid, 0)
132+
except ChildProcessError:
133+
# The child process is already reaped
134+
# (may happen if waitpid() is called elsewhere).
135+
pid = expected_pid
136+
returncode = 255
137+
logger.warning(
138+
"Unknown child process pid %d, will report returncode 255", pid
139+
)
140+
else:
141+
returncode = _compute_returncode(status)
142+
if loop.get_debug():
143+
logger.debug(
144+
"process %s exited with returncode %s", expected_pid, returncode
145+
)
146+
147+
if loop.is_closed():
148+
logger.warning("Loop %r that handles pid %r is closed", loop, pid)
149+
else:
150+
loop.call_soon_threadsafe(callback, pid, returncode, *args)
151+
152+
self._threads.pop(expected_pid)
153+
154+
155+
def _compute_returncode(status):
156+
if os.WIFSIGNALED(status):
157+
# The child process died because of a signal.
158+
return -os.WTERMSIG(status)
159+
elif os.WIFEXITED(status):
160+
# The child process exited (e.g sys.exit()).
161+
return os.WEXITSTATUS(status)
162+
else:
163+
# The child exited, but we don't understand its status.
164+
# This shouldn't happen, but if it does, let's just
165+
# return that status; perhaps that helps debug it.
166+
return status

playwright/_impl/_transport.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,6 @@ def request_stop(self) -> None:
110110

111111
async def wait_until_stopped(self) -> None:
112112
await self._stopped_future
113-
await self._proc.wait()
114113

115114
async def connect(self) -> None:
116115
self._stopped_future: asyncio.Future = asyncio.Future()
@@ -119,6 +118,19 @@ async def connect(self) -> None:
119118
if sys.platform == "win32" and sys.stdout is None:
120119
creationflags = subprocess.CREATE_NO_WINDOW
121120

121+
# In Python 3.7, self._proc.wait() hangs because it does not use ThreadedChildWatcher
122+
# which is used in Python 3.8+. This is unix specific and also takes care about
123+
# cleaning up zombie processes. See https://bugs.python.org/issue35621
124+
if (
125+
sys.version_info[0] == 3
126+
and sys.version_info[1] == 7
127+
and sys.platform != "win32"
128+
and isinstance(asyncio.get_child_watcher(), asyncio.SafeChildWatcher)
129+
):
130+
from ._py37ThreadedChildWatcher import ThreadedChildWatcher # type: ignore
131+
132+
watcher = ThreadedChildWatcher()
133+
asyncio.set_child_watcher(watcher)
122134
try:
123135
# For pyinstaller
124136
env = get_driver_env()
@@ -147,22 +159,30 @@ async def run(self) -> None:
147159
while not self._stopped:
148160
try:
149161
buffer = await self._proc.stdout.readexactly(4)
162+
if self._stopped:
163+
break
150164
length = int.from_bytes(buffer, byteorder="little", signed=False)
151165
buffer = bytes(0)
152166
while length:
153167
to_read = min(length, 32768)
154168
data = await self._proc.stdout.readexactly(to_read)
169+
if self._stopped:
170+
break
155171
length -= to_read
156172
if len(buffer):
157173
buffer = buffer + data
158174
else:
159175
buffer = data
176+
if self._stopped:
177+
break
160178

161179
obj = self.deserialize_message(buffer)
162180
self.on_message(obj)
163181
except asyncio.IncompleteReadError:
164182
break
165183
await asyncio.sleep(0)
184+
185+
await self._proc.wait()
166186
self._stopped_future.set_result(None)
167187

168188
def send(self, message: Dict) -> None:

playwright/async_api/_context_manager.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ async def __aenter__(self) -> AsyncPlaywright:
3737
loop.create_task(self._connection.run())
3838
playwright_future = self._connection.playwright_future
3939

40-
done, pending = await asyncio.wait(
40+
done, _ = await asyncio.wait(
4141
{self._connection._transport.on_error_future, playwright_future},
4242
return_when=asyncio.FIRST_COMPLETED,
4343
)

playwright/sync_api/_context_manager.py

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -29,34 +29,30 @@
2929
class PlaywrightContextManager:
3030
def __init__(self) -> None:
3131
self._playwright: SyncPlaywright
32+
self._loop: asyncio.AbstractEventLoop
33+
self._own_loop = False
3234

3335
def __enter__(self) -> SyncPlaywright:
34-
loop: asyncio.AbstractEventLoop
35-
own_loop = None
3636
try:
37-
loop = asyncio.get_running_loop()
37+
self._loop = asyncio.get_running_loop()
3838
except RuntimeError:
39-
loop = asyncio.new_event_loop()
40-
own_loop = loop
41-
if loop.is_running():
39+
self._loop = asyncio.new_event_loop()
40+
self._own_loop = True
41+
if self._loop.is_running():
4242
raise Error(
4343
"""It looks like you are using Playwright Sync API inside the asyncio loop.
4444
Please use the Async API instead."""
4545
)
4646

4747
def greenlet_main() -> None:
48-
loop.run_until_complete(self._connection.run_as_sync())
49-
50-
if own_loop:
51-
loop.run_until_complete(loop.shutdown_asyncgens())
52-
loop.close()
48+
self._loop.run_until_complete(self._connection.run_as_sync())
5349

5450
dispatcher_fiber = greenlet(greenlet_main)
5551
self._connection = Connection(
5652
dispatcher_fiber,
5753
create_remote_object,
58-
PipeTransport(loop, compute_driver_executable()),
59-
loop,
54+
PipeTransport(self._loop, compute_driver_executable()),
55+
self._loop,
6056
)
6157

6258
g_self = greenlet.getcurrent()
@@ -77,3 +73,6 @@ def start(self) -> SyncPlaywright:
7773

7874
def __exit__(self, *args: Any) -> None:
7975
self._connection.stop_sync()
76+
if self._own_loop:
77+
self._loop.run_until_complete(self._loop.shutdown_asyncgens())
78+
self._loop.close()

0 commit comments

Comments
 (0)