|
9 | 9 |
|
10 | 10 | import pytest |
11 | 11 |
|
| 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 | + |
12 | 17 | from pyrit.exceptions import ScenarioPartialFailureException |
13 | 18 | from pyrit.executor.attack.core import AttackExecutorResult |
14 | 19 | from pyrit.memory import CentralMemory |
@@ -627,3 +632,144 @@ async def mock_run(*args, **kwargs): |
627 | 632 | assert len(result.attack_results["attack_1"]) == 2 |
628 | 633 | assert len(result.attack_results["attack_2"]) == 3 |
629 | 634 | 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