Skip to content

Commit 678df09

Browse files
Merge pull request #243 from zmedico/fix_issue_95_WorkerPool_Wait_for_previous_task_in_try_send_to_primary_thread
Add main_thread_only execmodel
2 parents c0f794a + 772da33 commit 678df09

File tree

10 files changed

+202
-34
lines changed

10 files changed

+202
-34
lines changed

CHANGELOG.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,23 @@
11
2.1.0 (UNRELEASED)
2+
------------------
3+
4+
* `#243 <https://github.com/pytest-dev/execnet/pull/243>`__: Added ``main_thread_only``
5+
execmodel which is derived from the thread execmodel and only executes ``remote_exec``
6+
calls in the main thread.
27

8+
Callers of ``remote_exec`` must use the returned channel to wait for a task to complete
9+
before they call remote_exec again, otherwise the ``remote_exec`` call will fail with a
10+
``concurrent remote_exec would cause deadlock`` error. The main_thread_only execmodel
11+
provides solutions for `#96 <https://github.com/pytest-dev/execnet/issues/96>`__ and
12+
`pytest-dev/pytest-xdist#620 <https://github.com/pytest-dev/pytest-xdist/issues/620>`__
13+
(pending a new `pytest-xdist` release).
14+
15+
Also fixed ``init_popen_io`` to use ``closefd=False`` for shared stdin and stdout file
16+
descriptors, preventing ``Bad file descriptor`` errors triggered by test_stdouterrin_setnull.
317
* Removed support for Python 3.7.
418
* Added official support for Python 3.12.
519

20+
621
2.0.2 (2023-07-09)
722
------------------
823

doc/basics.rst

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -138,14 +138,14 @@ processes then you often want to call ``group.terminate()``
138138
yourself and specify a larger or not timeout.
139139

140140

141-
threading models: gevent, eventlet, thread
142-
===========================================
141+
threading models: gevent, eventlet, thread, main_thread_only
142+
====================================================================
143143

144144
.. versionadded:: 1.2 (status: experimental!)
145145

146-
execnet supports "thread", "eventlet" and "gevent" as thread models
147-
on each of the two sides. You need to decide which model to use
148-
before you create any gateways::
146+
execnet supports "main_thread_only", "thread", "eventlet" and "gevent"
147+
as thread models on each of the two sides. You need to decide which
148+
model to use before you create any gateways::
149149

150150
# content of threadmodel.py
151151
import execnet

src/execnet/gateway_base.py

Lines changed: 66 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ def sleep(self, delay):
6161
raise NotImplementedError()
6262

6363
@abc.abstractmethod
64-
def fdopen(self, fd, mode, bufsize=1):
64+
def fdopen(self, fd, mode, bufsize=1, closefd=True):
6565
raise NotImplementedError()
6666

6767
@abc.abstractmethod
@@ -113,10 +113,10 @@ def start(self, func, args=()):
113113

114114
return _thread.start_new_thread(func, args)
115115

116-
def fdopen(self, fd, mode, bufsize=1):
116+
def fdopen(self, fd, mode, bufsize=1, closefd=True):
117117
import os
118118

119-
return os.fdopen(fd, mode, bufsize, encoding="utf-8")
119+
return os.fdopen(fd, mode, bufsize, encoding="utf-8", closefd=closefd)
120120

121121
def Lock(self):
122122
import threading
@@ -134,6 +134,10 @@ def Event(self):
134134
return threading.Event()
135135

136136

137+
class MainThreadOnlyExecModel(ThreadExecModel):
138+
backend = "main_thread_only"
139+
140+
137141
class EventletExecModel(ExecModel):
138142
backend = "eventlet"
139143

@@ -170,10 +174,10 @@ def start(self, func, args=()):
170174

171175
return eventlet.spawn_n(func, *args)
172176

173-
def fdopen(self, fd, mode, bufsize=1):
177+
def fdopen(self, fd, mode, bufsize=1, closefd=True):
174178
import eventlet.green.os
175179

176-
return eventlet.green.os.fdopen(fd, mode, bufsize)
180+
return eventlet.green.os.fdopen(fd, mode, bufsize, closefd=closefd)
177181

178182
def Lock(self):
179183
import eventlet.green.threading
@@ -227,11 +231,11 @@ def start(self, func, args=()):
227231

228232
return gevent.spawn(func, *args)
229233

230-
def fdopen(self, fd, mode, bufsize=1):
234+
def fdopen(self, fd, mode, bufsize=1, closefd=True):
231235
# XXX
232236
import gevent.fileobject
233237

234-
return gevent.fileobject.FileObjectThread(fd, mode, bufsize)
238+
return gevent.fileobject.FileObjectThread(fd, mode, bufsize, closefd=closefd)
235239

236240
def Lock(self):
237241
import gevent.lock
@@ -254,6 +258,8 @@ def get_execmodel(backend):
254258
return backend
255259
if backend == "thread":
256260
return ThreadExecModel()
261+
elif backend == "main_thread_only":
262+
return MainThreadOnlyExecModel()
257263
elif backend == "eventlet":
258264
return EventletExecModel()
259265
elif backend == "gevent":
@@ -322,7 +328,7 @@ def __init__(self, execmodel, hasprimary=False):
322328
self._shuttingdown = False
323329
self._waitall_events = []
324330
if hasprimary:
325-
if self.execmodel.backend != "thread":
331+
if self.execmodel.backend not in ("thread", "main_thread_only"):
326332
raise ValueError("hasprimary=True requires thread model")
327333
self._primary_thread_task_ready = self.execmodel.Event()
328334
else:
@@ -332,7 +338,7 @@ def integrate_as_primary_thread(self):
332338
"""integrate the thread with which we are called as a primary
333339
thread for executing functions triggered with spawn().
334340
"""
335-
assert self.execmodel.backend == "thread", self.execmodel
341+
assert self.execmodel.backend in ("thread", "main_thread_only"), self.execmodel
336342
primary_thread_task_ready = self._primary_thread_task_ready
337343
# interacts with code at REF1
338344
while 1:
@@ -345,7 +351,11 @@ def integrate_as_primary_thread(self):
345351
with self._running_lock:
346352
if self._shuttingdown:
347353
break
348-
primary_thread_task_ready.clear()
354+
# Only clear if _try_send_to_primary_thread has not
355+
# yet set the next self._primary_thread_task reply
356+
# after waiting for this one to complete.
357+
if reply is self._primary_thread_task:
358+
primary_thread_task_ready.clear()
349359

350360
def trigger_shutdown(self):
351361
with self._running_lock:
@@ -376,6 +386,19 @@ def _try_send_to_primary_thread(self, reply):
376386
# wake up primary thread
377387
primary_thread_task_ready.set()
378388
return True
389+
elif (
390+
self.execmodel.backend == "main_thread_only"
391+
and self._primary_thread_task is not None
392+
):
393+
self._primary_thread_task.waitfinish()
394+
self._primary_thread_task = reply
395+
# wake up primary thread (it's okay if this is already set
396+
# because we waited for the previous task to finish above
397+
# and integrate_as_primary_thread will not clear it when
398+
# it enters self._running_lock if it detects that a new
399+
# task is available)
400+
primary_thread_task_ready.set()
401+
return True
379402
return False
380403

381404
def spawn(self, func, *args, **kwargs):
@@ -857,6 +880,9 @@ def reconfigure(self, py2str_as_py3str=True, py3str_as_py2str=False):
857880

858881
ENDMARKER = object()
859882
INTERRUPT_TEXT = "keyboard-interrupted"
883+
MAIN_THREAD_ONLY_DEADLOCK_TEXT = (
884+
"concurrent remote_exec would cause deadlock for main_thread_only execmodel"
885+
)
860886

861887

862888
class ChannelFactory:
@@ -1105,6 +1131,20 @@ def join(self, timeout=None):
11051131

11061132
class WorkerGateway(BaseGateway):
11071133
def _local_schedulexec(self, channel, sourcetask):
1134+
if self._execpool.execmodel.backend == "main_thread_only":
1135+
# It's necessary to wait for a short time in order to ensure
1136+
# that we do not report a false-positive deadlock error, since
1137+
# channel close does not elicit a response that would provide
1138+
# a guarantee to remote_exec callers that the previous task
1139+
# has released the main thread. If the timeout expires then it
1140+
# should be practically impossible to report a false-positive.
1141+
if not self._executetask_complete.wait(timeout=1):
1142+
channel.close(MAIN_THREAD_ONLY_DEADLOCK_TEXT)
1143+
return
1144+
# It's only safe to clear here because the above wait proves
1145+
# that there is not a previous task about to set it again.
1146+
self._executetask_complete.clear()
1147+
11081148
sourcetask = loads_internal(sourcetask)
11091149
self._execpool.spawn(self.executetask, (channel, sourcetask))
11101150

@@ -1132,8 +1172,14 @@ def serve(self):
11321172
def trace(msg):
11331173
self._trace("[serve] " + msg)
11341174

1135-
hasprimary = self.execmodel.backend == "thread"
1175+
hasprimary = self.execmodel.backend in ("thread", "main_thread_only")
11361176
self._execpool = WorkerPool(self.execmodel, hasprimary=hasprimary)
1177+
self._executetask_complete = None
1178+
if self.execmodel.backend == "main_thread_only":
1179+
self._executetask_complete = self.execmodel.Event()
1180+
# Initialize state to indicate that there is no previous task
1181+
# executing so that we don't need a separate flag to track this.
1182+
self._executetask_complete.set()
11371183
trace("spawning receiver thread")
11381184
self._initreceive()
11391185
try:
@@ -1176,6 +1222,11 @@ def executetask(self, item):
11761222
return
11771223
self._trace("ignoring EOFError because receiving finished")
11781224
channel.close()
1225+
if self._executetask_complete is not None:
1226+
# Indicate that this task has finished executing, meaning
1227+
# that there is no possibility of it triggering a deadlock
1228+
# for the next spawn call.
1229+
self._executetask_complete.set()
11791230

11801231

11811232
#
@@ -1631,8 +1682,10 @@ def init_popen_io(execmodel):
16311682
os.dup2(fd, 2)
16321683
os.close(fd)
16331684
io = Popen2IO(stdout, stdin, execmodel)
1634-
sys.stdin = execmodel.fdopen(0, "r", 1)
1635-
sys.stdout = execmodel.fdopen(1, "w", 1)
1685+
# Use closefd=False since 0 and 1 are shared with
1686+
# sys.__stdin__ and sys.__stdout__.
1687+
sys.stdin = execmodel.fdopen(0, "r", 1, closefd=False)
1688+
sys.stdout = execmodel.fdopen(1, "w", 1, closefd=False)
16361689
return io
16371690

16381691

src/execnet/multi.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ def makegateway(self, spec=None):
107107
108108
id=<string> specifies the gateway id
109109
python=<path> specifies which python interpreter to execute
110-
execmodel=model 'thread', 'eventlet', 'gevent' model for execution
110+
execmodel=model 'thread', 'main_thread_only', 'eventlet', 'gevent' model for execution
111111
chdir=<path> specifies to which directory to change
112112
nice=<path> specifies process priority of new process
113113
env:NAME=value specifies a remote environment variable setting.

testing/conftest.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ def anypython(request):
124124
pytest.skip(f"no {name} found")
125125
if "execmodel" in request.fixturenames and name != "sys.executable":
126126
backend = request.getfixturevalue("execmodel").backend
127-
if backend != "thread":
127+
if backend not in ("thread", "main_thread_only"):
128128
pytest.xfail(f"cannot run {backend!r} execmodel with bare {name}")
129129
return executable
130130

@@ -173,9 +173,11 @@ def gw(request, execmodel, group):
173173
return gw
174174

175175

176-
@pytest.fixture(params=["thread", "eventlet", "gevent"], scope="session")
176+
@pytest.fixture(
177+
params=["thread", "main_thread_only", "eventlet", "gevent"], scope="session"
178+
)
177179
def execmodel(request):
178-
if request.param != "thread":
180+
if request.param not in ("thread", "main_thread_only"):
179181
pytest.importorskip(request.param)
180182
if request.param in ("eventlet", "gevent") and sys.platform == "win32":
181183
pytest.xfail(request.param + " does not work on win32")

testing/test_basics.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -249,12 +249,25 @@ class Arg:
249249

250250
@pytest.mark.skipif("not hasattr(os, 'dup')")
251251
def test_stdouterrin_setnull(execmodel, capfd):
252-
gateway_base.init_popen_io(execmodel)
253-
os.write(1, b"hello")
254-
os.read(0, 1)
255-
out, err = capfd.readouterr()
256-
assert not out
257-
assert not err
252+
# Backup and restore stdin state, and rely on capfd to handle
253+
# this for stdout and stderr.
254+
orig_stdin = sys.stdin
255+
orig_stdin_fd = os.dup(0)
256+
try:
257+
# The returned Popen2IO instance can be garbage collected
258+
# prematurely since we don't hold a reference here, but we
259+
# tolerate this because it is intended to leave behind a
260+
# sane state afterwards.
261+
gateway_base.init_popen_io(execmodel)
262+
os.write(1, b"hello")
263+
os.read(0, 1)
264+
out, err = capfd.readouterr()
265+
assert not out
266+
assert not err
267+
finally:
268+
sys.stdin = orig_stdin
269+
os.dup2(orig_stdin_fd, 0)
270+
os.close(orig_stdin_fd)
258271

259272

260273
class PseudoChannel:

testing/test_gateway.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,3 +525,82 @@ def sendback(channel):
525525
if interleave_getstatus:
526526
print(gw.remote_status())
527527
assert ch.receive(timeout=0.5) == 1234
528+
529+
530+
def test_assert_main_thread_only(execmodel, makegateway):
531+
if execmodel.backend != "main_thread_only":
532+
pytest.skip("can only run with main_thread_only")
533+
534+
gw = makegateway(spec=f"execmodel={execmodel.backend}//popen")
535+
536+
try:
537+
# Submit multiple remote_exec requests in quick succession and
538+
# assert that all tasks execute in the main thread. It is
539+
# necessary to call receive on each channel before the next
540+
# remote_exec call, since the channel will raise an error if
541+
# concurrent remote_exec requests are submitted as in
542+
# test_main_thread_only_concurrent_remote_exec_deadlock.
543+
for i in range(10):
544+
ch = gw.remote_exec(
545+
"""
546+
import time, threading
547+
time.sleep(0.02)
548+
channel.send(threading.current_thread() is threading.main_thread())
549+
"""
550+
)
551+
552+
try:
553+
res = ch.receive()
554+
finally:
555+
ch.close()
556+
# This doesn't actually block because we closed
557+
# the channel already, but it does check for remote
558+
# errors and raise them.
559+
ch.waitclose()
560+
if res is not True:
561+
pytest.fail("remote raised\n%s" % res)
562+
finally:
563+
gw.exit()
564+
gw.join()
565+
566+
567+
def test_main_thread_only_concurrent_remote_exec_deadlock(execmodel, makegateway):
568+
if execmodel.backend != "main_thread_only":
569+
pytest.skip("can only run with main_thread_only")
570+
571+
gw = makegateway(spec=f"execmodel={execmodel.backend}//popen")
572+
channels = []
573+
try:
574+
# Submit multiple remote_exec requests in quick succession and
575+
# assert that MAIN_THREAD_ONLY_DEADLOCK_TEXT is raised if
576+
# concurrent remote_exec requests are submitted for the
577+
# main_thread_only execmodel (as compensation for the lack of
578+
# back pressure in remote_exec calls which do not attempt to
579+
# block until the remote main thread is idle).
580+
for i in range(2):
581+
channels.append(
582+
gw.remote_exec(
583+
"""
584+
import threading
585+
channel.send(threading.current_thread() is threading.main_thread())
586+
# Wait forever, ensuring that the deadlock case triggers.
587+
channel.gateway.execmodel.Event().wait()
588+
"""
589+
)
590+
)
591+
592+
expected_results = (
593+
True,
594+
execnet.gateway_base.MAIN_THREAD_ONLY_DEADLOCK_TEXT,
595+
)
596+
for expected, ch in zip(expected_results, channels):
597+
try:
598+
res = ch.receive()
599+
except execnet.RemoteError as e:
600+
res = e.formatted
601+
assert res == expected
602+
finally:
603+
for ch in channels:
604+
ch.close()
605+
gw.exit()
606+
gw.join()

0 commit comments

Comments
 (0)