Skip to content

Commit e4046c9

Browse files
committed
Add option to launch each test in separate process
Either specify directly on command line, or add tool.robotpy.pyfrc.multiprocess to pyproject.toml. If it works out, expect to make this default in 2026
1 parent f6f4e8e commit e4046c9

File tree

4 files changed

+261
-11
lines changed

4 files changed

+261
-11
lines changed

pyfrc/mains/cli_test.py

Lines changed: 68 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import logging
12
import os
23
from os.path import abspath
34
import inspect
@@ -7,6 +8,7 @@
78

89
import wpilib
910

11+
import tomli
1012
import pytest
1113

1214
from ..util import yesno
@@ -16,6 +18,8 @@
1618
# TODO: setting the plugins so that the end user can invoke pytest directly
1719
# could be a useful thing. Will have to consider that later.
1820

21+
logger = logging.getLogger("test")
22+
1923

2024
class _TryAgain(Exception):
2125
pass
@@ -37,6 +41,12 @@ def __init__(self, parser=None):
3741
action="store_true",
3842
help="Use pyfrc's builtin tests if no tests are specified",
3943
)
44+
parser.add_argument(
45+
"--isolated",
46+
default=None,
47+
action="store_true",
48+
help="Run each test in a separate robot process. Set `tool.robotpy.pyfrc.isolated` to true in your pyproject.toml to enable by default",
49+
)
4050
parser.add_argument(
4151
"--coverage-mode",
4252
default=False,
@@ -55,16 +65,47 @@ def run(
5565
project_path: pathlib.Path,
5666
robot_class: typing.Type[wpilib.RobotBase],
5767
builtin: bool,
68+
isolated: typing.Optional[bool],
5869
coverage_mode: bool,
70+
verbose: bool,
5971
pytest_args: typing.List[str],
6072
):
73+
if isolated is None:
74+
pyproject_path = project_path / "pyproject.toml"
75+
if pyproject_path.exists():
76+
with open(pyproject_path, "rb") as fp:
77+
d = tomli.load(fp)
78+
79+
try:
80+
v = d["tool"]["robotpy"]["pyfrc"]["isolated"]
81+
except KeyError:
82+
pass
83+
else:
84+
if not isinstance(v, bool):
85+
raise ValueError(
86+
f"tool.robotpy.pyfrc.isolated must be a boolean value (got {v})"
87+
)
88+
89+
isolated = v
90+
91+
if isolated is None:
92+
isolated = False
93+
94+
if not isolated:
95+
logger.info(
96+
"Isolated test mode not enabled, consider using it if your tests hang"
97+
)
98+
logger.info("- See 'robotpy test --help' for details")
99+
61100
try:
62101
return self._run_test(
63102
main_file,
64103
project_path,
65104
robot_class,
66105
builtin,
106+
isolated,
67107
coverage_mode,
108+
verbose,
68109
pytest_args,
69110
)
70111
except _TryAgain:
@@ -73,7 +114,9 @@ def run(
73114
project_path,
74115
robot_class,
75116
builtin,
117+
isolated,
76118
coverage_mode,
119+
verbose,
77120
pytest_args,
78121
)
79122

@@ -83,7 +126,9 @@ def _run_test(
83126
project_path: pathlib.Path,
84127
robot_class: typing.Type[wpilib.RobotBase],
85128
builtin: bool,
129+
isolated: bool,
86130
coverage_mode: bool,
131+
verbose: bool,
87132
pytest_args: typing.List[str],
88133
):
89134
# find test directory, change current directory so pytest can find the tests
@@ -92,14 +137,15 @@ def _run_test(
92137
curdir = pathlib.Path.cwd().absolute()
93138

94139
self.try_dirs = [
95-
(project_path / "tests").absolute(),
96-
(project_path / ".." / "tests").absolute(),
140+
((project_path / "tests").absolute(), False),
141+
((project_path / ".." / "tests").absolute(), True),
97142
]
98143

99-
for d in self.try_dirs:
144+
for d, chdir in self.try_dirs:
100145
if d.exists():
101-
test_directory = d
102-
os.chdir(test_directory)
146+
builtin = False
147+
if chdir:
148+
os.chdir(d)
103149
break
104150
else:
105151
if not builtin:
@@ -112,10 +158,22 @@ def _run_test(
112158
pytest_args.insert(0, abspath(inspect.getfile(basic)))
113159

114160
try:
115-
retv = pytest.main(
116-
pytest_args,
117-
plugins=[pytest_plugin.PyFrcPlugin(robot_class, main_file)],
118-
)
161+
if isolated:
162+
from ..test_support import pytest_dist_plugin
163+
164+
retv = pytest.main(
165+
pytest_args,
166+
plugins=[
167+
pytest_dist_plugin.DistPlugin(
168+
robot_class, main_file, builtin, verbose
169+
)
170+
],
171+
)
172+
else:
173+
retv = pytest.main(
174+
pytest_args,
175+
plugins=[pytest_plugin.PyFrcPlugin(robot_class, main_file, False)],
176+
)
119177
finally:
120178
os.chdir(curdir)
121179

@@ -132,7 +190,7 @@ def _no_tests(
132190
):
133191
print()
134192
print("Looked for tests at:")
135-
for d in self.try_dirs:
193+
for d, _ in self.try_dirs:
136194
print("-", d)
137195
print()
138196
print(
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import multiprocessing
2+
import os
3+
import pathlib
4+
import sys
5+
import time
6+
7+
from typing import Type
8+
9+
import pytest
10+
11+
import robotpy.main
12+
import robotpy.logconfig
13+
import wpilib
14+
15+
16+
from .pytest_plugin import PyFrcPlugin
17+
18+
19+
def _enable_faulthandler():
20+
#
21+
# In the event of a segfault, faulthandler will dump the currently
22+
# active stack so you can figure out what went wrong.
23+
#
24+
# Additionally, on non-Windows platforms we register a SIGUSR2
25+
# handler -- if you send the robot process a SIGUSR2, then
26+
# faulthandler will dump all of your current stacks. This can
27+
# be really useful for figuring out things like deadlocks.
28+
#
29+
30+
import logging
31+
32+
logger = logging.getLogger("faulthandler")
33+
34+
try:
35+
# These should work on all platforms
36+
import faulthandler
37+
38+
faulthandler.enable()
39+
except Exception as e:
40+
logger.warning("Could not enable faulthandler: %s", e)
41+
return
42+
43+
try:
44+
import signal
45+
46+
faulthandler.register(signal.SIGUSR2)
47+
logger.info("registered SIGUSR2 for PID %s", os.getpid())
48+
except Exception:
49+
return
50+
51+
52+
def _run_test(item_nodeid, config_args, robot_class, robot_file, verbose, pipe):
53+
"""This function runs in a subprocess"""
54+
robotpy.logconfig.configure_logging(verbose)
55+
_enable_faulthandler()
56+
57+
# This is used by getDeployDirectory, so make sure it gets fixed
58+
robotpy.main.robot_py_path = robot_file
59+
60+
# keep the plugin around because it has a reference to the robot
61+
# and we don't want it to die and deadlock
62+
plugin = PyFrcPlugin(robot_class, robot_file, True)
63+
64+
ec = pytest.main(
65+
[item_nodeid, "--no-header", *config_args],
66+
plugins=[plugin],
67+
)
68+
69+
# ensure output is printed out
70+
# .. TODO could implement pytest_runtestloop and send the
71+
# test result back to the parent and print it there?
72+
sys.stdout.flush()
73+
74+
# Don't let the process die, let the parent kill us to avoid
75+
# python interpreter badness
76+
pipe.send(ec)
77+
78+
# ensure that the gc doesn't collect the plugin..
79+
while plugin:
80+
time.sleep(100)
81+
82+
83+
def _run_test_in_new_process(
84+
test_function, config, robot_class, robot_file, builtin_tests, verbose
85+
):
86+
"""Run a test function in a new process."""
87+
88+
config_args = config.invocation_params.args
89+
if builtin_tests:
90+
item_nodeid = f"{config_args[0]}::{test_function.name}"
91+
config_args = config_args[1:]
92+
else:
93+
item_nodeid = test_function.nodeid
94+
95+
parent, child = multiprocessing.Pipe()
96+
97+
process = multiprocessing.Process(
98+
target=_run_test,
99+
args=(item_nodeid, config_args, robot_class, robot_file, verbose, child),
100+
)
101+
process.start()
102+
try:
103+
ec = parent.recv()
104+
finally:
105+
process.kill()
106+
107+
if ec != 0:
108+
pytest.fail(
109+
f"Test failed in subprocess: {item_nodeid} (exit code {ec})",
110+
pytrace=False,
111+
)
112+
113+
114+
def _make_runtest(item, config, robot_class, robot_file, builtin_tests, verbose):
115+
def isolated_runtest():
116+
_run_test_in_new_process(
117+
item, config, robot_class, robot_file, builtin_tests, verbose
118+
)
119+
120+
return isolated_runtest
121+
122+
123+
class DistPlugin:
124+
125+
def __init__(
126+
self,
127+
robot_class: Type[wpilib.RobotBase],
128+
robot_file: pathlib.Path,
129+
builtin_tests: bool,
130+
verbose: bool,
131+
) -> None:
132+
self._robot_class = robot_class
133+
self._robot_file = robot_file
134+
self._builtin_tests = builtin_tests
135+
self._verbose = verbose
136+
137+
@pytest.hookimpl(tryfirst=True)
138+
def pytest_collection_modifyitems(
139+
self,
140+
session: pytest.Session,
141+
config: pytest.Config,
142+
items: list[pytest.Function],
143+
):
144+
"""Modify collected test items to run each in a new process."""
145+
146+
multiprocessing.set_start_method("spawn")
147+
148+
for item in items:
149+
# Overwrite the runtest protocol for each item
150+
item.runtest = _make_runtest(
151+
item,
152+
config,
153+
self._robot_class,
154+
self._robot_file,
155+
self._builtin_tests,
156+
self._verbose,
157+
)
158+
159+
#
160+
# These fixtures match the ones in PyFrcPlugin but these have no effect
161+
#
162+
163+
@pytest.fixture(scope="function", autouse=True)
164+
def robot(self):
165+
pass
166+
167+
@pytest.fixture(scope="function")
168+
def control(self, reraise, robot):
169+
pass
170+
171+
@pytest.fixture()
172+
def robot_file(self):
173+
pass
174+
175+
@pytest.fixture()
176+
def robot_path(self):
177+
pass

pyfrc/test_support/pytest_plugin.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,14 @@ class PyFrcPlugin:
3131
be passed to your test function.
3232
"""
3333

34-
def __init__(self, robot_class: Type[wpilib.RobotBase], robot_file: pathlib.Path):
34+
def __init__(
35+
self,
36+
robot_class: Type[wpilib.RobotBase],
37+
robot_file: pathlib.Path,
38+
isolated: bool,
39+
):
40+
self.isolated = isolated
41+
3542
# attach physics
3643
physics, robot_class = PhysicsInterface._create_and_attach(
3744
robot_class,
@@ -103,6 +110,13 @@ def robot(self):
103110
# Tests only get a proxy to ensure cleanup is more reliable
104111
yield weakref.proxy(robot)
105112

113+
# If running in separate processes, no need to do cleanup
114+
if self.isolated:
115+
# .. and funny enough, in isolated mode we *don't* want the
116+
# robot to be cleaned up, as that can deadlock
117+
self._saved_robot = robot
118+
return
119+
106120
# reset engine to ensure it gets cleaned up too
107121
# -> might be holding wpilib objects, or the robot
108122
if self._physics:

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ install_requires =
2929

3030
wpilib>=2025.1.1,<2026
3131
robotpy-cli~=2024.0
32+
tomli
3233
setup_requires =
3334
setuptools_scm > 6
3435
python_requires = >=3.9

0 commit comments

Comments
 (0)