Skip to content

Commit 4022a67

Browse files
Merge pull request #419 from RonnyPfannschmidt/fix/safe-terminate-timeout-and-gevent-fdopen
fix: bound safe_terminate waits and gevent stdio exit
2 parents 775b4b8 + f700163 commit 4022a67

3 files changed

Lines changed: 70 additions & 10 deletions

File tree

‎src/execnet/gateway_base.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,10 +273,12 @@ def start(self, func, args=()) -> None:
273273
gevent.spawn(func, *args)
274274

275275
def fdopen(self, fd, mode, bufsize=1, closefd=True):
276-
# XXX
277276
import gevent.fileobject
278277

279-
return gevent.fileobject.FileObjectThread(fd, mode, bufsize, closefd=closefd)
278+
# Prefer FileObject (FileObjectPosix on Unix). FileObjectThread keeps a
279+
# native threadpool alive and can prevent interpreter shutdown, which
280+
# hangs tests/scripts that open stdio via init_popen_io and then exit.
281+
return gevent.fileobject.FileObject(fd, mode, bufsize, closefd=closefd)
280282

281283
def Lock(self):
282284
import gevent.lock

‎src/execnet/multi.py‎

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from typing import TYPE_CHECKING
1818
from typing import Any
1919
from typing import Literal
20+
from typing import TypeAlias
2021
from typing import overload
2122

2223
from . import gateway_bootstrap
@@ -328,25 +329,44 @@ def waitclose(self) -> None:
328329
raise first
329330

330331

332+
TermKillFunc: TypeAlias = Callable[[], object]
333+
TermKillPair: TypeAlias = tuple[TermKillFunc, TermKillFunc]
334+
335+
331336
def safe_terminate(
332-
execmodel: ExecModel, timeout: float | None, list_of_paired_functions
337+
execmodel: ExecModel,
338+
timeout: float | None,
339+
list_of_paired_functions: Sequence[TermKillPair],
333340
) -> None:
341+
"""Run terminate/kill pairs in parallel with a hard wait bound.
342+
343+
Each termfunc is given ``timeout``. If it does not finish, killfunc runs.
344+
Waiting for the worker pool is also bounded so a stuck kill cannot hang
345+
the caller forever (see issues #43 / #221).
346+
"""
334347
workerpool = WorkerPool(execmodel)
335348

336-
def termkill(termfunc, killfunc) -> None:
349+
def termkill(termfunc: TermKillFunc, killfunc: TermKillFunc) -> None:
337350
termreply = workerpool.spawn(termfunc)
338351
try:
339352
termreply.get(timeout=timeout)
340353
except OSError:
341354
killfunc()
342355

343-
replylist = []
344-
for termfunc, killfunc in list_of_paired_functions:
345-
reply = workerpool.spawn(termkill, termfunc, killfunc)
346-
replylist.append(reply)
356+
replylist = [
357+
workerpool.spawn(termkill, termfunc, killfunc)
358+
for termfunc, killfunc in list_of_paired_functions
359+
]
360+
# Allow term timeout plus a kill attempt; never block indefinitely.
361+
wait_timeout = None if timeout is None else timeout * 2
347362
for reply in replylist:
348-
reply.get()
349-
workerpool.waitall(timeout=timeout)
363+
try:
364+
reply.waitfinish(timeout=wait_timeout)
365+
except OSError:
366+
# termkill still running (typically stuck in killfunc).
367+
continue
368+
reply.get() # propagate worker exceptions, if any
369+
workerpool.waitall(timeout=wait_timeout)
350370

351371

352372
default_group = Group()

‎testing/test_multi.py‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,3 +278,41 @@ def kill() -> None:
278278
sleep(0.1)
279279
gc.collect()
280280
assert threading.active_count() == active
281+
282+
283+
@pytest.mark.timeout(5)
284+
def test_safe_terminate_does_not_hang_when_kill_blocks(
285+
execmodel: ExecModel,
286+
) -> None:
287+
"""Regression for #43/#221: a stuck kill must not hang terminate forever.
288+
289+
Before the fix, reply.get() waited without a timeout after termfunc timed
290+
out, so a blocking killfunc made Group.terminate() hang indefinitely
291+
(seen via pytest-xdist teardown).
292+
"""
293+
kill_started = execmodel.Event()
294+
release_kill = execmodel.Event()
295+
other_killed: list[int] = []
296+
297+
def term_slow() -> None:
298+
execmodel.sleep(10)
299+
300+
def kill_hang() -> None:
301+
kill_started.set()
302+
release_kill.wait()
303+
304+
def kill_ok() -> None:
305+
other_killed.append(1)
306+
307+
safe_terminate(
308+
execmodel,
309+
0.2,
310+
[
311+
(term_slow, kill_hang),
312+
(term_slow, kill_ok),
313+
],
314+
)
315+
316+
assert kill_started.is_set()
317+
assert other_killed == [1]
318+
release_kill.set()

0 commit comments

Comments
 (0)