Skip to content

Commit 03e83b8

Browse files
author
cxzhong
committed
Python 3.14: Support forkserver and fix free-threaded build
- Remove forced fork() from conftest.py to allow Python 3.14's default forkserver multiprocessing start method - Add PSelecter.wait_for_process() method that monitors process.sentinel instead of SIGCHLD, working with any start method (fork/spawn/forkserver) - Fix ob_refcnt direct access for free-threaded build by using Py_REFCNT() macro (required for Python 3.14t nogil build) - Update doctests to demonstrate both SIGCHLD-based (fork-only) and sentinel-based (universal) process monitoring approaches This allows cysignals to work properly with Python 3.14's new defaults while maintaining backward compatibility.
1 parent 1389e13 commit 03e83b8

File tree

3 files changed

+93
-19
lines changed

3 files changed

+93
-19
lines changed

src/conftest.py

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,6 @@
1919
"cysignals/tests.pyx",
2020
]
2121

22-
# Python 3.14+ changed the default multiprocessing start method to 'forkserver'
23-
# on Linux, which breaks SIGCHLD-based tests. Set it back to 'fork' for compatibility.
24-
if sys.version_info >= (3, 14) and platform.system() != "Windows":
25-
import multiprocessing
26-
try:
27-
multiprocessing.set_start_method('fork', force=True)
28-
except RuntimeError:
29-
# Method may already be set
30-
pass
31-
3222

3323
def pytest_collect_file(
3424
file_path: pathlib.Path,

src/cysignals/pselect.pyx

Lines changed: 91 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,31 @@ We wait for a child created using the ``subprocess`` module::
3333
>>> p.poll() # p should be finished
3434
0
3535
36-
Now using the ``multiprocessing`` module::
36+
Now using the ``multiprocessing`` module with ANY start method::
3737
3838
>>> from cysignals.pselect import PSelecter
39-
>>> from multiprocessing import *
40-
>>> import time
39+
>>> from multiprocessing import get_context
40+
>>> import time, sys
41+
>>> # Works with any start method - uses process sentinel
42+
>>> ctx = get_context() # Uses default (forkserver on 3.14+, fork on older)
43+
>>> with PSelecter() as sel:
44+
... p = ctx.Process(target=time.sleep, args=(0.5,))
45+
... p.start()
46+
... # Monitor process.sentinel instead of SIGCHLD
47+
... r, w, x, t = sel.pselect(rlist=[p.sentinel], timeout=2)
48+
... p.is_alive() # p should be finished
49+
False
50+
51+
For SIGCHLD-based monitoring (requires 'fork' on Python 3.14+)::
52+
53+
>>> import signal
54+
>>> def dummy_handler(sig, frame):
55+
... pass
56+
>>> _ = signal.signal(signal.SIGCHLD, dummy_handler)
57+
>>> # Use 'fork' method for SIGCHLD to work properly
58+
>>> ctx = get_context('fork') if sys.version_info >= (3, 14) else get_context()
4159
>>> with PSelecter([signal.SIGCHLD]) as sel:
42-
... p = Process(target=time.sleep, args=(1,))
60+
... p = ctx.Process(target=time.sleep, args=(0.5,))
4361
... p.start()
4462
... _ = sel.sleep()
4563
... p.is_alive() # p should be finished
@@ -289,12 +307,14 @@ cdef class PSelecter:
289307
290308
Start a process which will cause a ``SIGCHLD`` signal::
291309
292-
>>> import time
293-
>>> from multiprocessing import *
310+
>>> import time, sys, signal
311+
>>> from multiprocessing import get_context
294312
>>> from cysignals.pselect import PSelecter, interruptible_sleep
313+
>>> # For SIGCHLD, must use 'fork' on Python 3.14+
314+
>>> ctx = get_context('fork') if sys.version_info >= (3, 14) else get_context()
295315
>>> w = PSelecter([signal.SIGCHLD])
296316
>>> with w:
297-
... p = Process(target=time.sleep, args=(0.25,))
317+
... p = ctx.Process(target=time.sleep, args=(0.25,))
298318
... t0 = time.time()
299319
... p.start()
300320
@@ -501,3 +521,67 @@ cdef class PSelecter:
501521
502522
"""
503523
return self.pselect(timeout=timeout)[3]
524+
525+
def wait_for_process(self, process, timeout=None):
526+
"""
527+
Wait until a multiprocessing.Process exits or timeout occurs.
528+
529+
This works with ANY multiprocessing start method (fork, spawn, forkserver)
530+
by monitoring the process sentinel file descriptor instead of SIGCHLD.
531+
532+
NOTE: This is POSIX-only. On Windows, use ``multiprocessing.connection.wait()``
533+
instead.
534+
535+
INPUT:
536+
537+
- ``process`` -- a ``multiprocessing.Process`` object
538+
539+
- ``timeout`` -- (default: ``None``) a timeout in seconds,
540+
where ``None`` stands for no timeout.
541+
542+
OUTPUT: A boolean which is ``True`` if the call timed out,
543+
False if the process exited.
544+
545+
EXAMPLES:
546+
547+
Works with any multiprocessing start method::
548+
549+
>>> from cysignals.pselect import PSelecter
550+
>>> from multiprocessing import get_context
551+
>>> import time
552+
>>> # Use default start method (forkserver on 3.14+)
553+
>>> ctx = get_context()
554+
>>> sel = PSelecter()
555+
>>> p = ctx.Process(target=time.sleep, args=(0.1,))
556+
>>> p.start()
557+
>>> timed_out = sel.wait_for_process(p, timeout=1)
558+
>>> timed_out # Should be False (process exited)
559+
False
560+
>>> p.is_alive()
561+
False
562+
563+
Can also be used in a with block::
564+
565+
>>> with PSelecter() as sel:
566+
... p = ctx.Process(target=time.sleep, args=(0.1,))
567+
... p.start()
568+
... timed_out = sel.wait_for_process(p, timeout=1)
569+
>>> timed_out
570+
False
571+
572+
TESTS:
573+
574+
Timeout case::
575+
576+
>>> p = ctx.Process(target=time.sleep, args=(10,))
577+
>>> p.start()
578+
>>> timed_out = sel.wait_for_process(p, timeout=0.1)
579+
>>> timed_out # Should be True (timeout)
580+
True
581+
>>> p.terminate()
582+
>>> p.join()
583+
584+
"""
585+
_, _, _, timed_out = self.pselect(rlist=[process.sentinel], timeout=timeout)
586+
return timed_out
587+

src/cysignals/signals.pyx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,7 @@ cdef void verify_exc_value() noexcept:
361361
Check that ``cysigs.exc_value`` is still the exception being raised.
362362
Clear ``cysigs.exc_value`` if not.
363363
"""
364-
if cysigs.exc_value.ob_refcnt == 1:
364+
if Py_REFCNT(cysigs.exc_value) == 1:
365365
# No other references => exception is certainly gone
366366
Py_CLEAR(cysigs.exc_value)
367367
return
@@ -409,5 +409,5 @@ cdef void verify_exc_value() noexcept:
409409
# Make sure we still have cysigs.exc_value at all; if this function was
410410
# called again during garbage collection it might have already been set
411411
# to NULL; see https://github.com/sagemath/cysignals/issues/126
412-
if cysigs.exc_value != NULL and cysigs.exc_value.ob_refcnt == 1:
412+
if cysigs.exc_value != NULL and Py_REFCNT(cysigs.exc_value) == 1:
413413
Py_CLEAR(cysigs.exc_value)

0 commit comments

Comments
 (0)