From ed32d757f2a168c4dd4d67cb9b04aaae1dfc0793 Mon Sep 17 00:00:00 2001 From: Zeel Desai Date: Fri, 10 Apr 2026 00:40:05 -0400 Subject: [PATCH 1/4] Add Python 3.13 and 3.14 support --- .github/workflows/build.yaml | 2 +- .github/workflows/validate.yaml | 2 +- pyproject.toml | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index a207f13..c14eb08 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -48,7 +48,7 @@ jobs: strategy: fail-fast: false matrix: - python_version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python_version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] steps: - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml index acbf87e..adda169 100644 --- a/.github/workflows/validate.yaml +++ b/.github/workflows/validate.yaml @@ -19,7 +19,7 @@ jobs: - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: '3.12-dev' + python-version: "3.14" - name: Install Python dependencies run: | python3 -m pip install -r requirements-dev.txt diff --git a/pyproject.toml b/pyproject.toml index d42508a..8d7b5b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,8 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Framework :: Pytest", ] From 37f4039cfa7c34797a1fc46417f3b59132b649ec Mon Sep 17 00:00:00 2001 From: Matt Wozniski Date: Sat, 6 Jun 2026 22:37:36 -0400 Subject: [PATCH 2/4] Handle multiprocessing start methods besides fork Since Python 3.14 'forkserver' is now the default multiprocessing start method instead of 'fork', and with this spawn method we need to pass the `multiprocessing.Event` that we're sharing between the pytest process and the monitoring process along explicitly, rather than counting on a `fork()` to share the same object between the two processes. --- src/pytest_pystack/_monitor_process.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pytest_pystack/_monitor_process.py b/src/pytest_pystack/_monitor_process.py index b24fa0a..974db0d 100644 --- a/src/pytest_pystack/_monitor_process.py +++ b/src/pytest_pystack/_monitor_process.py @@ -20,6 +20,7 @@ def start(config): config, os.getpid(), _queue, + debug_detected, ), name="pystack_monitor", ) @@ -35,7 +36,7 @@ def stop(): _process.kill() -def _run_monitor(config: PystackConfig, pid, queue): +def _run_monitor(config: PystackConfig, pid, queue, debug_detected): pystack_cmd = [ config.pystack_path, "remote", From ef2ee8ee1e289b825ebbaa6634792d73cd522661 Mon Sep 17 00:00:00 2001 From: Matt Wozniski Date: Sun, 7 Jun 2026 03:36:47 +0000 Subject: [PATCH 3/4] Use the "spawn" start method instead of forkserver Python 3.14 defaults to the "forkserver" multiprocessing start method, instead of the previous "fork" default. This new default causes all sorts of problems for us. The hardest to fix one, which made me throw in the towel on trying to get things working with "forkserver", is that pytest's `pytester` fixture defaults to running the new pytest session in the current process, rather than from a subprocess, and we're using `capfd` to capture the stderr. With the "fork" method and the "spawn" method, the new process inherits whatever the parent process's stderr (fd 2) is pointed to at the time when the new process is started, but with "forkserver" the server gets created once and then reused, and all future children have their stderr pointed wherever the first child process's stderr was pointed to. There's other issues too. We leak semaphores because the monitoring process winds up creating an unnecessary `Event` and `Queue` that it ignores in favor of the ones passed to its entry point. That could be fixed by creating those lazily instead of at import time, but that leads to pickling failures because the `multiprocessing.connection` module gets created lazily, and `pytester` unloads modules that were loaded by the tests, which leads to two different copies of that module getting loaded, with the old module still referenced by some things. We could work around all of this in our test suite, but using our plugin along with the `pytester` fixture is a reasonable use case that end users might want, if they're trying to use `pytest-pystack` to test their own pytest plugin. The most reasonable solution is for us to detect when "forkserver" would be used, and to use "spawn" instead in those cases. --- src/pytest_pystack/_debug_detect.py | 5 +++-- src/pytest_pystack/_monitor_process.py | 6 +++--- src/pytest_pystack/_multiprocessing_context.py | 7 +++++++ 3 files changed, 13 insertions(+), 5 deletions(-) create mode 100644 src/pytest_pystack/_multiprocessing_context.py diff --git a/src/pytest_pystack/_debug_detect.py b/src/pytest_pystack/_debug_detect.py index 5e2784f..d8eafa1 100644 --- a/src/pytest_pystack/_debug_detect.py +++ b/src/pytest_pystack/_debug_detect.py @@ -1,8 +1,9 @@ import inspect # used for introspection of module name -import multiprocessing import sys -debug_detected = multiprocessing.Event() +from ._multiprocessing_context import MP_CTX + +debug_detected = MP_CTX.Event() # bdb covers pdb, ipdb, and possibly others # pydevd covers PyCharm, VSCode, and possibly others KNOWN_DEBUGGING_MODULES = {"pydevd", "bdb", "pydevd_frame_evaluator"} diff --git a/src/pytest_pystack/_monitor_process.py b/src/pytest_pystack/_monitor_process.py index 974db0d..971d30d 100644 --- a/src/pytest_pystack/_monitor_process.py +++ b/src/pytest_pystack/_monitor_process.py @@ -1,4 +1,3 @@ -import multiprocessing import os import shlex import subprocess @@ -7,14 +6,15 @@ from ._config import PystackConfig from ._debug_detect import debug_detected +from ._multiprocessing_context import MP_CTX -_queue = multiprocessing.Queue() +_queue = MP_CTX.Queue() _process = None def start(config): global _process - _process = multiprocessing.Process( + _process = MP_CTX.Process( target=_run_monitor, args=( config, diff --git a/src/pytest_pystack/_multiprocessing_context.py b/src/pytest_pystack/_multiprocessing_context.py new file mode 100644 index 0000000..75e2e4e --- /dev/null +++ b/src/pytest_pystack/_multiprocessing_context.py @@ -0,0 +1,7 @@ +import multiprocessing + +# Ensure the "forkserver" spawn method is never used, because we have +# known incompatibilities with it, while both "fork" and "spawn" work. +MP_CTX = multiprocessing.get_context( + "fork" if multiprocessing.get_start_method() == "fork" else "spawn" +) From 66671d12e761a184c8eda6d92fc26a1b171cc385 Mon Sep 17 00:00:00 2001 From: Matt Wozniski Date: Sun, 7 Jun 2026 04:07:56 -0400 Subject: [PATCH 4/4] Use the "spawn" start method unconditionally Rather than trying to support both "fork" and "spawn", force "spawn" to be used no matter what. Split the monitor process's entry point out into a separate module to avoid having a global multiprocessing `Queue` object get created when it is reimported in the spawned process, as otherwise Python 3.8 and 3.9 report that some semaphores are leaked by the child process. --- src/pytest_pystack/_monitor.py | 53 ++++++++++++++++++ src/pytest_pystack/_monitor_process.py | 55 +------------------ .../_multiprocessing_context.py | 10 ++-- 3 files changed, 60 insertions(+), 58 deletions(-) create mode 100644 src/pytest_pystack/_monitor.py diff --git a/src/pytest_pystack/_monitor.py b/src/pytest_pystack/_monitor.py new file mode 100644 index 0000000..e6a1c8a --- /dev/null +++ b/src/pytest_pystack/_monitor.py @@ -0,0 +1,53 @@ +import shlex +import subprocess +import sys +from queue import Empty + +from ._config import PystackConfig + + +def monitor(config: PystackConfig, pid, queue, debug_detected): + pystack_cmd = [ + config.pystack_path, + "remote", + ] + if config.pystack_args: + pystack_cmd += shlex.split(config.pystack_args) + handled_test_cases = set() + while True: + testcase = queue.get() + if testcase is None: + break + + if testcase in handled_test_cases: + continue + handled_test_cases.add(testcase) + + try: + new_testcase = queue.get(timeout=config.threshold) + if new_testcase != testcase: + print( + f"new test {new_testcase} should not start before previous {testcase} test finished", + file=sys.__stderr__, + ) + raise Exception( + "new test should not start before previous test finished" + ) + except Empty: + output = "" + output += f"\n\n**** PYSTACK -- {testcase} ***\n" + output += f"Timed out waiting for process {pid} to finish {testcase}:" + proc = subprocess.run( + [*pystack_cmd, str(pid)], + stdout=subprocess.PIPE, + text=True, + ) + output += proc.stdout + output += "**** PYSTACK ***\n" + is_debug = debug_detected.is_set() + debug_detected.clear() + if config.print_stderr and not is_debug: + print(output, file=sys.__stderr__) + if config.output_file: + with open(config.output_file, "a") as f: + print(output, file=f) diff --git a/src/pytest_pystack/_monitor_process.py b/src/pytest_pystack/_monitor_process.py index 971d30d..5ac6dcb 100644 --- a/src/pytest_pystack/_monitor_process.py +++ b/src/pytest_pystack/_monitor_process.py @@ -1,11 +1,7 @@ import os -import shlex -import subprocess -import sys -from queue import Empty -from ._config import PystackConfig from ._debug_detect import debug_detected +from ._monitor import monitor from ._multiprocessing_context import MP_CTX _queue = MP_CTX.Queue() @@ -15,7 +11,7 @@ def start(config): global _process _process = MP_CTX.Process( - target=_run_monitor, + target=monitor, args=( config, os.getpid(), @@ -34,50 +30,3 @@ def stop(): _process.join(timeout=5) if _process.is_alive(): _process.kill() - - -def _run_monitor(config: PystackConfig, pid, queue, debug_detected): - pystack_cmd = [ - config.pystack_path, - "remote", - ] - if config.pystack_args: - pystack_cmd += shlex.split(config.pystack_args) - handled_test_cases = set() - while True: - testcase = queue.get() - if testcase is None: - break - - if testcase in handled_test_cases: - continue - handled_test_cases.add(testcase) - - try: - new_testcase = queue.get(timeout=config.threshold) - if new_testcase != testcase: - print( - f"new test {new_testcase} should not start before previous {testcase} test finished", - file=sys.__stderr__, - ) - raise Exception( - "new test should not start before previous test finished" - ) - except Empty: - output = "" - output += f"\n\n**** PYSTACK -- {testcase} ***\n" - output += f"Timed out waiting for process {pid} to finish {testcase}:" - proc = subprocess.run( - [*pystack_cmd, str(pid)], - stdout=subprocess.PIPE, - text=True, - ) - output += proc.stdout - output += "**** PYSTACK ***\n" - is_debug = debug_detected.is_set() - debug_detected.clear() - if config.print_stderr and not is_debug: - print(output, file=sys.__stderr__) - if config.output_file: - with open(config.output_file, "a") as f: - print(output, file=f) diff --git a/src/pytest_pystack/_multiprocessing_context.py b/src/pytest_pystack/_multiprocessing_context.py index 75e2e4e..ddc21c7 100644 --- a/src/pytest_pystack/_multiprocessing_context.py +++ b/src/pytest_pystack/_multiprocessing_context.py @@ -1,7 +1,7 @@ import multiprocessing -# Ensure the "forkserver" spawn method is never used, because we have -# known incompatibilities with it, while both "fork" and "spawn" work. -MP_CTX = multiprocessing.get_context( - "fork" if multiprocessing.get_start_method() == "fork" else "spawn" -) +# We have known incompatibilities with the "forkserver" start method. +# The "fork" method is expected to work, but can potentially lead to +# strange failure modes because of things being inherited by our monitor +# process that shouldn't have been. Use "spawn" unconditionally instead. +MP_CTX = multiprocessing.get_context("spawn")