Skip to content

Commit 296f016

Browse files
authored
gh-109817: Add --single-process-per-case option to libregrtest (GH-151689)
1 parent 025e7d2 commit 296f016

7 files changed

Lines changed: 379 additions & 39 deletions

File tree

Lib/test/libregrtest/cmdline.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ def __init__(self, **kwargs) -> None:
194194
self._add_python_opts = True
195195
self.xmlpath = None
196196
self.single_process = False
197+
self.single_process_per_case = False
197198

198199
super().__init__(**kwargs)
199200

@@ -368,6 +369,11 @@ def _create_parser():
368369
group.add_argument('--list-cases', action='store_true',
369370
help='only write the name of test cases that will be run, '
370371
'don\'t execute them')
372+
group.add_argument('--single-process-per-case', action='store_true',
373+
help='run each test case in its own process. '
374+
'(slow; for debugging order dependencies '
375+
"and environment leaks). Test cases from "
376+
'the same module run sequentially.')
371377
group.add_argument('-P', '--pgo', dest='pgo', action='store_true',
372378
help='enable Profile Guided Optimization (PGO) training')
373379
group.add_argument('--pgo-extended', action='store_true',
@@ -492,6 +498,17 @@ def _parse_args(args, **kwargs):
492498
if ns.single_process:
493499
ns.use_mp = None
494500

501+
if ns.single_process_per_case:
502+
if ns.rerun:
503+
parser.error("--single-process-per-case and --rerun "
504+
"options don't go together")
505+
if ns.pgo:
506+
parser.error("--single-process-per-case and --pgo "
507+
"options don't go together")
508+
if ns.single_process:
509+
parser.error("--single-process-per-case and --single-process "
510+
"options don't go together")
511+
495512
# When both --slow-ci and --fast-ci options are present,
496513
# --slow-ci has the priority
497514
if ns.slow_ci:

Lib/test/libregrtest/findtests.py

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -93,20 +93,58 @@ def list_cases(tests: TestTuple, *,
9393
match_tests: TestFilter | None = None,
9494
test_dir: StrPath | None = None) -> None:
9595
support.verbose = False
96-
set_match_tests(match_tests)
96+
cases_by_module, skipped = collect_cases(tests, match_tests=match_tests,
97+
test_dir=test_dir)
98+
for cases in cases_by_module.values():
99+
for case_id in cases:
100+
print(case_id)
101+
if skipped:
102+
sys.stdout.flush()
103+
stderr = sys.stderr
104+
print(file=stderr)
105+
print(count(len(skipped), "test"), "skipped:", file=stderr)
106+
printlist(skipped, file=stderr)
107+
108+
class _ModuleLoadFailed(Exception):
109+
"""The test module failed to load; its test cases are unknown."""
110+
97111

98-
skipped = []
112+
def collect_cases(tests: TestTuple, *,
113+
match_tests: TestFilter | None = None,
114+
test_dir: StrPath | None = None
115+
) -> tuple[dict[TestName, list[str]], list[TestName]]:
116+
# Install the filter unconditionally: passing None clears any
117+
# previously installed global filter, so collection is not
118+
# affected by unrelated state in this process.
119+
set_match_tests(match_tests)
120+
result: dict[TestName, list[str]] = {}
121+
skipped: list[TestName] = []
99122
for test_name in tests:
100123
module_name = abs_module_name(test_name, test_dir)
124+
cases: list[str] = []
101125
try:
102126
suite = unittest.defaultTestLoader.loadTestsFromName(module_name)
103-
_list_cases(suite)
127+
_collect_cases(suite, cases)
104128
except unittest.SkipTest:
105129
skipped.append(test_name)
130+
continue
131+
except _ModuleLoadFailed:
132+
# The module failed to load. Run it as a whole, so that the
133+
# error is reported as in the normal mode.
134+
result[test_name] = [test_name]
135+
continue
136+
if cases:
137+
result[test_name] = cases
138+
return result, skipped
106139

107-
if skipped:
108-
sys.stdout.flush()
109-
stderr = sys.stderr
110-
print(file=stderr)
111-
print(count(len(skipped), "test"), "skipped:", file=stderr)
112-
printlist(skipped, file=stderr)
140+
def _collect_cases(suite: unittest.TestSuite, out: list[str]) -> None:
141+
for test in suite:
142+
if isinstance(test, unittest.TestSuite):
143+
_collect_cases(test, out)
144+
elif isinstance(test, unittest.loader._FailedTest): # type: ignore[attr-defined]
145+
# The test module failed to load. Its test cases are
146+
# unknown: let the caller run the whole module.
147+
raise _ModuleLoadFailed
148+
elif isinstance(test, unittest.TestCase):
149+
if match_test(test):
150+
out.append(test.id())

Lib/test/libregrtest/main.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from test.support import os_helper, MS_WINDOWS, flush_std_streams
1313

1414
from .cmdline import _parse_args, Namespace
15-
from .findtests import findtests, split_test_packages, list_cases
15+
from .findtests import findtests, split_test_packages, list_cases, collect_cases
1616
from .logger import Logger
1717
from .pgo import setup_pgo_tests
1818
from .result import TestResult
@@ -73,6 +73,7 @@ def __init__(self, ns: Namespace, _add_python_opts: bool = False):
7373
self.want_header: bool = ns.header
7474
self.want_list_tests: bool = ns.list_tests
7575
self.want_list_cases: bool = ns.list_cases
76+
self.want_single_process_per_case: bool = ns.single_process_per_case
7677
self.want_wait: bool = ns.wait
7778
self.want_cleanup: bool = ns.cleanup
7879
self.want_rerun: bool = ns.rerun
@@ -99,6 +100,10 @@ def __init__(self, ns: Namespace, _add_python_opts: bool = False):
99100
else:
100101
num_workers = ns.use_mp # run in parallel
101102
self.num_workers: int = num_workers
103+
if ns.single_process_per_case and ns.use_mp is None:
104+
# Each test case runs in its own worker subprocess;
105+
# default to one worker when -j was not given.
106+
self.num_workers = 1
102107
self.worker_json: StrJSON | None = ns.worker_json
103108

104109
# Options to run tests
@@ -521,6 +526,8 @@ def create_run_tests(self, tests: TestTuple) -> RunTests:
521526
randomize=self.randomize,
522527
random_seed=self.random_seed,
523528
parallel_threads=self.parallel_threads,
529+
single_process_per_case=self.want_single_process_per_case,
530+
case_groups=None,
524531
)
525532

526533
def _run_tests(self, selected: TestTuple, tests: TestList | None) -> int:
@@ -546,6 +553,23 @@ def _run_tests(self, selected: TestTuple, tests: TestList | None) -> int:
546553
print("Using random seed:", self.random_seed)
547554

548555
runtests = self.create_run_tests(selected)
556+
if self.want_single_process_per_case:
557+
cases_by_module, _ = collect_cases(
558+
selected,
559+
match_tests=self.match_tests,
560+
test_dir=self.test_dir)
561+
groups = []
562+
for module_name in selected:
563+
cases = cases_by_module.get(module_name)
564+
if cases:
565+
groups.append((module_name, tuple(cases)))
566+
else:
567+
groups.append((module_name, (module_name,)))
568+
case_groups = tuple(groups)
569+
case_ids = tuple(
570+
case_id for _, cases in case_groups for case_id in cases
571+
)
572+
runtests = runtests.copy(tests=case_ids, case_groups=case_groups)
549573
self.first_runtests = runtests
550574
self.logger.set_tests(runtests)
551575

Lib/test/libregrtest/run_workers.py

Lines changed: 46 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,6 @@ def stop(self):
7070
with self.lock:
7171
self.tests_iter = None
7272

73-
7473
@dataclasses.dataclass(slots=True, frozen=True)
7574
class MultiprocessResult:
7675
result: TestResult
@@ -269,16 +268,22 @@ def create_json_file(self, stack: contextlib.ExitStack) -> tuple[JsonFile, TextI
269268
json_file = JsonFile(json_fd, JsonFileType.UNIX_FD)
270269
return (json_file, json_tmpfile)
271270

272-
def create_worker_runtests(self, test_name: TestName, json_file: JsonFile) -> WorkerRunTests:
273-
tests = (test_name,)
274-
if self.runtests.rerun:
275-
match_tests = self.runtests.get_match_tests(test_name)
271+
def create_worker_runtests(self, test_name: TestName,
272+
json_file: JsonFile,
273+
module_name: TestName | None = None,
274+
) -> WorkerRunTests:
275+
kwargs: dict[str, Any] = {}
276+
277+
if module_name is not None and test_name != module_name:
278+
tests = (module_name,)
279+
kwargs['match_tests'] = [(test_name, True)]
276280
else:
277-
match_tests = None
281+
tests = (test_name,)
282+
if self.runtests.rerun:
283+
match_tests = self.runtests.get_match_tests(test_name)
284+
if match_tests:
285+
kwargs['match_tests'] = [(test, True) for test in match_tests]
278286

279-
kwargs: dict[str, Any] = {}
280-
if match_tests:
281-
kwargs['match_tests'] = [(test, True) for test in match_tests]
282287
if self.runtests.output_on_failure:
283288
kwargs['verbose'] = True
284289
kwargs['output_on_failure'] = False
@@ -356,11 +361,13 @@ def read_json(self, json_file: JsonFile, json_tmpfile: TextIO | None,
356361

357362
return (result, stdout)
358363

359-
def _runtest(self, test_name: TestName) -> MultiprocessResult:
364+
def _runtest(self, test_name: TestName,
365+
module_name: TestName | None = None) -> MultiprocessResult:
360366
with contextlib.ExitStack() as stack:
361367
stdout_file = self.create_stdout(stack)
362368
json_file, json_tmpfile = self.create_json_file(stack)
363-
worker_runtests = self.create_worker_runtests(test_name, json_file)
369+
worker_runtests = self.create_worker_runtests(
370+
test_name, json_file, module_name=module_name)
364371

365372
retcode: str | int | None
366373
retcode, tmp_files = self.run_tmp_files(worker_runtests,
@@ -393,26 +400,38 @@ def _runtest(self, test_name: TestName) -> MultiprocessResult:
393400
def run(self) -> None:
394401
fail_fast = self.runtests.fail_fast
395402
fail_env_changed = self.runtests.fail_env_changed
403+
single_process_per_case = self.runtests.single_process_per_case
396404
try:
397-
while not self._stopped:
405+
stop = False
406+
while not self._stopped and not stop:
398407
try:
399-
test_name = next(self.pending)
408+
module_name, case_ids = next(self.pending)
400409
except StopIteration:
401410
break
402411

403-
self.start_time = time.monotonic()
404-
self.test_name = test_name
405-
try:
406-
mp_result = self._runtest(test_name)
407-
except WorkerError as exc:
408-
mp_result = exc.mp_result
409-
finally:
410-
self.test_name = _NOT_RUNNING
411-
mp_result.result.duration = time.monotonic() - self.start_time
412-
self.output.put((False, mp_result))
413-
414-
if mp_result.result.must_stop(fail_fast, fail_env_changed):
415-
break
412+
# All cases of a group run sequentially on this thread
413+
for test_name in case_ids:
414+
if self._stopped:
415+
break
416+
self.start_time = time.monotonic()
417+
self.test_name = test_name
418+
try:
419+
mp_result = self._runtest(
420+
test_name,
421+
module_name if single_process_per_case else None)
422+
except WorkerError as exc:
423+
mp_result = exc.mp_result
424+
finally:
425+
self.test_name = _NOT_RUNNING
426+
mp_result.result.duration = time.monotonic() - self.start_time
427+
if single_process_per_case:
428+
# Report the test case, not the test module
429+
mp_result.result.test_name = test_name
430+
self.output.put((False, mp_result))
431+
432+
if mp_result.result.must_stop(fail_fast, fail_env_changed):
433+
stop = True
434+
break
416435
except ExitThread:
417436
pass
418437
except BaseException:
@@ -489,8 +508,7 @@ def __init__(self, num_workers: int, runtests: RunTests,
489508
self.live_worker_count = 0
490509

491510
self.output: queue.Queue[QueueContent] = queue.Queue()
492-
tests_iter = runtests.iter_tests()
493-
self.pending = MultiprocessIterator(tests_iter)
511+
self.pending = MultiprocessIterator(runtests.iter_case_groups())
494512
self.timeout = runtests.timeout
495513
if self.timeout is not None:
496514
# Rely on faulthandler to kill a worker process. This timouet is

Lib/test/libregrtest/runtests.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,8 @@ class RunTests:
101101
randomize: bool
102102
random_seed: int | str
103103
parallel_threads: int | None
104+
single_process_per_case: bool
105+
case_groups: tuple[tuple[TestName, tuple[TestName, ...]], ...] | None
104106

105107
def copy(self, **override) -> 'RunTests':
106108
state = dataclasses.asdict(self)
@@ -132,6 +134,20 @@ def iter_tests(self) -> Iterator[TestName]:
132134
else:
133135
yield from self.tests
134136

137+
def iter_case_groups(self) -> Iterator[tuple[TestName, tuple[TestName, ...]]]:
138+
"""
139+
Yield (module_name, case_ids) pairs. All case_ids in a group
140+
must run sequentially on the same worker thread.
141+
"""
142+
if self.case_groups is None:
143+
for name in self.iter_tests():
144+
yield (name, (name,))
145+
elif self.forever:
146+
while True:
147+
yield from self.case_groups
148+
else:
149+
yield from self.case_groups
150+
135151
def json_file_use_stdout(self) -> bool:
136152
# Use STDOUT in two cases:
137153
#

0 commit comments

Comments
 (0)