Skip to content

Commit 1bb3a47

Browse files
romanlutzCopilot
andauthored
TEST Add concurrent partial failure resume coverage (#2411)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 626df98 commit 1bb3a47

1 file changed

Lines changed: 146 additions & 0 deletions

File tree

tests/unit/scenario/core/test_scenario_partial_results.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@
99

1010
import pytest
1111

12+
try:
13+
from builtins import ExceptionGroup # type: ignore[attr-defined,ty:unresolved-import]
14+
except ImportError: # pragma: no cover - 3.10 only
15+
from exceptiongroup import ExceptionGroup # type: ignore[no-redef,ty:unresolved-import]
16+
1217
from pyrit.exceptions import ScenarioPartialFailureException
1318
from pyrit.executor.attack.core import AttackExecutorResult
1419
from pyrit.memory import CentralMemory
@@ -627,3 +632,144 @@ async def mock_run(*args, **kwargs):
627632
assert len(result.attack_results["attack_1"]) == 2
628633
assert len(result.attack_results["attack_2"]) == 3
629634
assert len(result.attack_results["attack_3"]) == 1
635+
636+
async def test_concurrent_partial_failures_resume_without_duplicate_results(self, mock_objective_target):
637+
"""
638+
Concurrent partial failures should surface together and resume only unfinished objectives.
639+
640+
Three attacks start together. Two persist one result each before failing, while the
641+
third succeeds. A second run must execute only the two unfinished objectives.
642+
"""
643+
attack_a = create_mock_atomic_attack("attack-a", ["a-complete", "a-retry"])
644+
attack_b = create_mock_atomic_attack("attack-b", ["b-complete", "b-retry"])
645+
attack_c = create_mock_atomic_attack("attack-c", ["c-complete"])
646+
647+
all_started = asyncio.Event()
648+
attack_a_finished = asyncio.Event()
649+
attack_b_finished = asyncio.Event()
650+
started_attacks: set[str] = set()
651+
objective_batches: dict[str, list[list[str]]] = {"attack-a": [], "attack-b": [], "attack-c": []}
652+
653+
async def wait_until_all_started_async(*, attack_name: str) -> None:
654+
started_attacks.add(attack_name)
655+
if len(started_attacks) == 3:
656+
all_started.set()
657+
await all_started.wait()
658+
659+
def save_result(*, objective: str, attack: MagicMock) -> AttackResult:
660+
result = AttackResult(
661+
conversation_id=f"conv-{objective}",
662+
objective=objective,
663+
outcome=AttackOutcome.SUCCESS,
664+
executed_turns=1,
665+
)
666+
save_attack_results_to_memory([result], atomic_attack=attack)
667+
return result
668+
669+
async def run_attack_a_async(*args, **kwargs) -> AttackExecutorResult[AttackResult]:
670+
objectives = list(attack_a.objectives)
671+
objective_batches["attack-a"].append(objectives)
672+
if len(objective_batches["attack-a"]) == 1:
673+
await wait_until_all_started_async(attack_name="attack-a")
674+
completed = save_result(objective="a-complete", attack=attack_a)
675+
attack_a_finished.set()
676+
return AttackExecutorResult(
677+
completed_results=[completed],
678+
incomplete_objectives=[("a-retry", RuntimeError("attack-a interrupted"))],
679+
)
680+
completed = save_result(objective="a-retry", attack=attack_a)
681+
return AttackExecutorResult(completed_results=[completed], incomplete_objectives=[])
682+
683+
async def run_attack_b_async(*args, **kwargs) -> AttackExecutorResult[AttackResult]:
684+
objectives = list(attack_b.objectives)
685+
objective_batches["attack-b"].append(objectives)
686+
if len(objective_batches["attack-b"]) == 1:
687+
await wait_until_all_started_async(attack_name="attack-b")
688+
await attack_a_finished.wait()
689+
completed = save_result(objective="b-complete", attack=attack_b)
690+
attack_b_finished.set()
691+
return AttackExecutorResult(
692+
completed_results=[completed],
693+
incomplete_objectives=[("b-retry", TimeoutError("attack-b timed out"))],
694+
)
695+
completed = save_result(objective="b-retry", attack=attack_b)
696+
return AttackExecutorResult(completed_results=[completed], incomplete_objectives=[])
697+
698+
async def run_attack_c_async(*args, **kwargs) -> AttackExecutorResult[AttackResult]:
699+
objectives = list(attack_c.objectives)
700+
objective_batches["attack-c"].append(objectives)
701+
await wait_until_all_started_async(attack_name="attack-c")
702+
await attack_b_finished.wait()
703+
completed = save_result(objective="c-complete", attack=attack_c)
704+
return AttackExecutorResult(completed_results=[completed], incomplete_objectives=[])
705+
706+
attack_a.run_async = AsyncMock(side_effect=run_attack_a_async)
707+
attack_b.run_async = AsyncMock(side_effect=run_attack_b_async)
708+
attack_c.run_async = AsyncMock(side_effect=run_attack_c_async)
709+
710+
scenario = ConcreteScenario(
711+
name="Concurrent partial failure scenario",
712+
version=1,
713+
atomic_attacks_to_return=[attack_a, attack_b, attack_c],
714+
)
715+
scenario.set_params_from_args(
716+
args={
717+
"objective_target": mock_objective_target,
718+
"max_concurrency": 3,
719+
"max_retries": 0,
720+
}
721+
)
722+
await scenario.initialize_async()
723+
724+
with patch.object(
725+
scenario._memory,
726+
"update_scenario_run_state",
727+
wraps=scenario._memory.update_scenario_run_state,
728+
) as update_state:
729+
with pytest.raises(ExceptionGroup) as exc_info:
730+
await asyncio.wait_for(scenario.run_async(), timeout=10)
731+
732+
assert all(isinstance(error, ScenarioPartialFailureException) for error in exc_info.value.exceptions)
733+
partial_failures = {
734+
error.atomic_attack_name: error
735+
for error in exc_info.value.exceptions
736+
if isinstance(error, ScenarioPartialFailureException)
737+
}
738+
assert set(partial_failures) == {"attack-a", "attack-b"}
739+
assert isinstance(partial_failures["attack-a"].incomplete_objectives[0][1], RuntimeError)
740+
assert isinstance(partial_failures["attack-b"].incomplete_objectives[0][1], TimeoutError)
741+
742+
failed_result = scenario._memory.get_scenario_results(scenario_result_ids=[scenario._scenario_result_id])[0]
743+
assert failed_result.scenario_run_state == ScenarioRunState.FAILED
744+
assert failed_result.number_tries == 1
745+
746+
final_result = await asyncio.wait_for(scenario.run_async(), timeout=10)
747+
748+
assert final_result.scenario_run_state == ScenarioRunState.COMPLETED
749+
assert final_result.number_tries == 2
750+
assert objective_batches == {
751+
"attack-a": [["a-complete", "a-retry"], ["a-retry"]],
752+
"attack-b": [["b-complete", "b-retry"], ["b-retry"]],
753+
"attack-c": [["c-complete"]],
754+
}
755+
assert attack_a.run_async.await_count == 2
756+
assert attack_b.run_async.await_count == 2
757+
assert attack_c.run_async.await_count == 1
758+
759+
stored_results = [
760+
result for attack_results in final_result.attack_results.values() for result in attack_results
761+
]
762+
assert sorted(result.objective for result in stored_results) == [
763+
"a-complete",
764+
"a-retry",
765+
"b-complete",
766+
"b-retry",
767+
"c-complete",
768+
]
769+
assert len({result.attack_result_id for result in stored_results}) == 5
770+
assert [call.kwargs["scenario_run_state"] for call in update_state.call_args_list] == [
771+
ScenarioRunState.IN_PROGRESS,
772+
ScenarioRunState.FAILED,
773+
ScenarioRunState.IN_PROGRESS,
774+
ScenarioRunState.COMPLETED,
775+
]

0 commit comments

Comments
 (0)