Skip to content

Commit 78bba5d

Browse files
authored
test_manager: Fix empty-jobs deadlock on state exhaustion (#512)
Fix the issue of waiting infinitely long on an empty set of active jobs. This edge case could occur when can_transform_now() returns True seeing a pass with a nonempty state, but the subsequent maybe_schedule_job () doesn't manage to schedule any job because all remaining states got skipped (due to the advance_while_subset_of_succeeded() logic). We'd end up calling wait() with only one future - the sigmonitor - and no job future; effectively it's an infinite sleep instead of bailing out the C-Vise event loop. The fix is to double-check the jobs count before wait()'ing. The issue was discovered as consistent failures of test_dir_linker_duplicate_var_error on a single-core machine. This fixes #508.
1 parent 3f3cfb9 commit 78bba5d

3 files changed

Lines changed: 73 additions & 12 deletions

File tree

cvise/tests/test_test_manager.py

Lines changed: 64 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import pytest
1414

1515
from cvise.passes.abstract import AbstractPass, PassResult # noqa: E402
16-
from cvise.passes.hint_based import HintBasedPass # noqa: E402
16+
from cvise.passes.hint_based import HintBasedPass, HintState # noqa: E402
1717
from cvise.utils import sigmonitor, statistics, testing # noqa: E402
1818
from cvise.utils.fileutil import filter_files_by_patterns
1919
from cvise.utils.hint import Hint, HintBundle, Patch
@@ -23,7 +23,7 @@
2323
baz
2424
"""
2525

26-
PARALLEL_TESTS = 10
26+
DEFAULT_PARALLEL_TESTS = 10
2727

2828

2929
class StubPass(AbstractPass):
@@ -291,7 +291,23 @@ def with_colordiff(fp, with_tty) -> None:
291291

292292

293293
@pytest.fixture
294-
def manager(tmp_path: Path, input_path: Path, interestingness_script: str, job_timeout: int, print_diff: bool):
294+
def parallel_tests() -> int:
295+
"""The default parallel_tests parameter.
296+
297+
Can be overridden in particular tests.
298+
"""
299+
return DEFAULT_PARALLEL_TESTS
300+
301+
302+
@pytest.fixture
303+
def manager(
304+
tmp_path: Path,
305+
input_path: Path,
306+
interestingness_script: str,
307+
job_timeout: int,
308+
print_diff: bool,
309+
parallel_tests: int,
310+
):
295311
SAVE_TEMPS = False
296312
NO_CACHE = False
297313
SKIP_KEY_OFF = True # tests shouldn't listen to keyboard
@@ -317,7 +333,7 @@ def manager(tmp_path: Path, input_path: Path, interestingness_script: str, job_t
317333
job_timeout,
318334
SAVE_TEMPS,
319335
[input_path],
320-
PARALLEL_TESTS,
336+
parallel_tests,
321337
NO_CACHE,
322338
SKIP_KEY_OFF,
323339
SHADDAP,
@@ -412,7 +428,7 @@ def test_give_up_on_repeating_timeouts(input_path: Path, manager):
412428
manager.run_passes([p], interleaving=False)
413429
assert extra_dir_count() >= manager.MAX_TIMEOUTS
414430
# we should've stopped soon after MAX_TIMEOUTS, at worst a batch of jobs later.
415-
assert extra_dir_count() <= 2 * max(manager.MAX_TIMEOUTS, PARALLEL_TESTS)
431+
assert extra_dir_count() <= 2 * max(manager.MAX_TIMEOUTS, DEFAULT_PARALLEL_TESTS)
416432

417433

418434
def test_interleaving_letter_removals(input_path: Path, manager):
@@ -429,7 +445,7 @@ def test_interleaving_letter_removals(input_path: Path, manager):
429445

430446

431447
@pytest.mark.skipif(os.name != 'posix', reason='requires POSIX for command-line tools')
432-
@pytest.mark.parametrize('input_contents', ['ababacac' * PARALLEL_TESTS])
448+
@pytest.mark.parametrize('input_contents', ['ababacac' * DEFAULT_PARALLEL_TESTS])
433449
@pytest.mark.parametrize('interestingness_script', [r"grep a {test_case} && ! grep '\(.\)\1' {test_case}"])
434450
def test_interleaving_letter_removals_large(input_path: Path, manager):
435451
"""Test that multiple passes executed in interleaving way can delete all but one character.
@@ -452,20 +468,22 @@ def test_interleaving_letter_removals_large(input_path: Path, manager):
452468
@pytest.mark.parametrize('interestingness_script', [r'false {test_case}'])
453469
def test_interleaving_round_robin_transforms(manager: testing.TestManager):
454470
tracing_queue = multiprocessing.Manager().Queue()
455-
passes = [TracingHintPass(tracing_queue, letters_to_remove=chr(ord('a') + i)) for i in range(PARALLEL_TESTS)]
471+
passes = [
472+
TracingHintPass(tracing_queue, letters_to_remove=chr(ord('a') + i)) for i in range(DEFAULT_PARALLEL_TESTS)
473+
]
456474
manager.run_passes(passes, interleaving=True)
457475

458476
transform_calls = []
459477
while not tracing_queue.empty():
460478
transform_calls.append(tracing_queue.get())
461479

462480
# all passes should've gotten equal number of jobs
463-
execs_per_pass = [transform_calls.count(str(i)) for i in range(PARALLEL_TESTS)]
481+
execs_per_pass = [transform_calls.count(str(i)) for i in range(DEFAULT_PARALLEL_TESTS)]
464482
assert min(execs_per_pass) == max(execs_per_pass)
465483
# we cannot assert the ideal round-robin order (like 123..N123..) because concurrent writes to the queue are racy,
466484
# but at least it's almost guaranteed that no pass should be recorded N times in a row.
467-
for i in range(len(transform_calls) - PARALLEL_TESTS + 1):
468-
slice = transform_calls[i : i + PARALLEL_TESTS]
485+
for i in range(len(transform_calls) - DEFAULT_PARALLEL_TESTS + 1):
486+
slice = transform_calls[i : i + DEFAULT_PARALLEL_TESTS]
469487
assert min(slice) != max(slice)
470488

471489

@@ -637,3 +655,39 @@ def _assert_stats_validity(pass_statistic: statistics.PassStatistic, start_time:
637655
assert stat.worked + stat.failed <= stat.totally_executed
638656
elapsed = time.monotonic() - start_time
639657
assert sum(stat.total_seconds for stat in stats) <= elapsed
658+
659+
660+
class MockHintState(HintState):
661+
def subset_of(self, other: HintState) -> bool:
662+
# We only want to skip state 2 if state 1 has already succeeded
663+
return self.ptr == 2 and other.ptr == 1
664+
665+
def real_chunk(self) -> int:
666+
return 1
667+
668+
669+
class StateSkippingPass(AbstractPass):
670+
"""A pass that triggers the subset skipping logic, causing its state to exhaust unexpectedly."""
671+
672+
def new(self, test_case: Path, *args, **kwargs) -> MockHintState:
673+
return MockHintState(tmp_dir=Path(), per_type_states=(), ptr=1, special_hints=())
674+
675+
def advance(self, test_case: Path, state: MockHintState) -> MockHintState | None:
676+
if state.ptr == 1:
677+
return MockHintState(tmp_dir=Path(), per_type_states=(), ptr=2, special_hints=())
678+
return None
679+
680+
def advance_on_success(self, test_case: Path, state: MockHintState, *args, **kwargs) -> MockHintState | None:
681+
return self.advance(test_case, state)
682+
683+
def transform(self, test_case: Path, state: MockHintState, *args, **kwargs) -> tuple[PassResult, MockHintState]:
684+
with open(test_case, 'a') as f:
685+
f.write(f'modification {state.ptr}\n')
686+
return (PassResult.OK, state)
687+
688+
689+
@pytest.mark.parametrize('parallel_tests', [1])
690+
def test_scheduler_deadlock_on_empty_jobs(input_path: Path, manager: testing.TestManager):
691+
"""Verifies that the orchestrator avoids deadlock when subset-skipping exhausts remaining states."""
692+
693+
manager.run_passes([StateSkippingPass()], interleaving=True)

cvise/utils/testing.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -913,6 +913,11 @@ def run_parallel_tests(self) -> None:
913913
while len(self.jobs) < self.parallel_tests and self.maybe_schedule_job():
914914
pass
915915

916+
if not self.jobs:
917+
# If maybe_schedule_job() couldn't schedule any work (e.g. because states were exhausted)
918+
# and there are no active jobs left, we must break to avoid a deadlock in wait().
919+
break
920+
916921
# no more jobs could be scheduled at the moment - wait for some results
917922
wait(
918923
[j.future for j in self.jobs] + [sigmonitor.get_future()],

tests/test_cvise.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,8 @@ def test_dir_test_case(tmp_path: Path, overridden_subprocess_tmpdir: Path):
283283
assert (test_case / 'a.cc').read_text() == '#include "a.h"\nint nextHi = x;\n'
284284

285285

286-
def test_dir_linker_duplicate_var_error(tmp_path: Path, overridden_subprocess_tmpdir: Path):
286+
@pytest.mark.parametrize('extra_args', [[], ['-n', '1']], ids=['default_cores', 'single_core'])
287+
def test_dir_linker_duplicate_var_error(tmp_path: Path, overridden_subprocess_tmpdir: Path, extra_args: list[str]):
287288
"""Test reducing headers and a makefile for a link-time error due to duplicate variables.
288289
289290
Here we had to hardcode particular error messages from real linkers.
@@ -320,7 +321,8 @@ def test_dir_linker_duplicate_var_error(tmp_path: Path, overridden_subprocess_tm
320321
f"(LC_ALL=C make -C repro 2>&1 || true) | awk '{{ print }} /{ERROR_REGEX}/ {{ y=1 }} END {{ exit !y }}'",
321322
'repro',
322323
'--tidy',
323-
],
324+
]
325+
+ extra_args,
324326
tmp_path,
325327
overridden_subprocess_tmpdir,
326328
)

0 commit comments

Comments
 (0)