Skip to content

Commit 27fe979

Browse files
authored
Merge pull request #238 from robotpy/separate-process
Add option to launch each test in separate process
2 parents f6f4e8e + e4046c9 commit 27fe979

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)