Skip to content

Commit 86e4735

Browse files
authored
fix(PLU-326): stop treating failed analyses as complete in status poller (#29)
**Context:** `PeriodicChecker._worker`, the analysis-status poller, treated any status other than `Queued`/`Processing` as a completed, successful analysis — including `Error`. That caused it to persist the failed analysis as attached and then crash on a downstream 422, since the backend has no function map to return for a failed analysis.
1 parent bd2f5a0 commit 86e4735

3 files changed

Lines changed: 127 additions & 31 deletions

File tree

‎reai_toolkit/utils/monitoring/process_binary_monitor.py‎

Lines changed: 50 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -4,80 +4,99 @@
44
import revengai
55
from binaryninja import log_info, log_error, BinaryView
66
from requests.exceptions import RequestException
7-
from PySide6.QtCore import QObject, Signal
7+
from PySide6.QtCore import QObject
88
from reai_toolkit.utils.core.sync import AnalysisSyncService
99

10+
_IN_PROGRESS_STATUSES = (
11+
revengai.StatusInput.UPLOADED,
12+
revengai.StatusInput.QUEUED,
13+
revengai.StatusInput.PROCESSING,
14+
)
15+
16+
1017
class PeriodicChecker(QObject):
11-
update_text_signal = Signal(object, str)
1218
sync_service: AnalysisSyncService
13-
19+
1420
def __init__(self, config):
1521
super().__init__()
1622
self._current_timer: Optional[Timer] = None
1723
self.number_of_clicks = 0
18-
self.update_text_signal.connect(self._update_text_slot)
1924
self.config = config
2025
self.sync_service = AnalysisSyncService(config)
2126

22-
def _update_text_slot(self, callback, text):
23-
"""Slot that runs in the main thread to safely update UI"""
24-
try:
25-
if hasattr(callback, '__call__'):
26-
if hasattr(self, '_current_editor'):
27-
callback(self._current_editor, text)
28-
except Exception as ex:
29-
log_error(f"RevEng.AI | Error updating UI: {str(ex)}")
30-
3127
def stop(self):
3228
if self._current_timer:
3329
self._current_timer.cancel()
3430
self._current_timer = None
3531
log_info("RevEng.AI | Stopped periodic status check")
36-
3732

38-
def start_checking(self, binary_view: BinaryView, analysis_id: int, binary_id: int, callback, interval: float = 60) -> None:
33+
def start_checking(
34+
self,
35+
binary_view: BinaryView,
36+
analysis_id: int,
37+
binary_id: int,
38+
callback,
39+
interval: float = 60,
40+
) -> None:
3941
def _worker(bv: BinaryView, bid: int, aid: int):
4042
try:
4143
with self.config.create_api_client() as api_client:
4244
api_instance = revengai.AnalysesCoreApi(api_client)
43-
api_response = api_instance.get_analysis_status(aid)
45+
api_response = api_instance.get_analysis_status(aid)
4446
status = api_response.data.analysis_status
45-
log_info(f"RevEng.AI | Current status for analysis [Binary ID: {bid}] [Analysis ID: {aid}]: {status}")
47+
log_info(
48+
f"RevEng.AI | Current status for analysis [Binary ID: {bid}] [Analysis ID: {aid}]: {status}"
49+
)
4650

47-
if status in ("Queued", "Processing"):
51+
if status in _IN_PROGRESS_STATUSES:
4852
if bv and bv.file and bv.file.filename:
4953
self._current_timer = Timer(
50-
interval,
51-
_worker,
52-
args=(bv, bid, aid)
54+
interval, _worker, args=(bv, bid, aid)
5355
)
5456
self._current_timer.start()
5557
log_info(
5658
f"RevEng.AI | Scheduled next status check for: {basename(bv.file.filename)} [Binary ID: {bid}] [Analysis ID: {aid}]"
5759
)
58-
else:
59-
60+
elif status == revengai.StatusInput.COMPLETE:
6061
# Analysis is complete, fetch model_id and invoke callback
6162
with self.config.create_api_client() as api_client:
6263
api_instance = revengai.AnalysesCoreApi(api_client)
63-
analysis_details: revengai.BaseResponseBasic = api_instance.get_analysis_basic_info(
64-
analysis_id=analysis_id
64+
analysis_details: revengai.BaseResponseBasic = (
65+
api_instance.get_analysis_basic_info(
66+
analysis_id=analysis_id
67+
)
6568
)
6669
model_id = analysis_details.data.model_id
6770
callback(bid, aid, model_id)
6871

69-
bv = self.sync_service.sync_analysis_data(analysis_id=aid, bv=bv)
72+
bv = self.sync_service.sync_analysis_data(
73+
analysis_id=aid, bv=bv
74+
)
7075

71-
log_info(f"RevEng.AI | Analysis completed with status: {status} for Binary ID: {bid} | Analysis ID: {aid} | Model ID: {model_id}")
76+
log_info(
77+
f"RevEng.AI | Analysis completed with status: {status} for Binary ID: {bid} | Analysis ID: {aid} | Model ID: {model_id}"
78+
)
79+
else:
80+
log_error(
81+
f"RevEng.AI | Analysis failed with status '{status}' "
82+
f"[Binary ID: {bid}] [Analysis ID: {aid}]. "
83+
"Check the analysis log in the RevEng.AI portal, then re-run the analysis."
84+
)
7285
except RequestException as ex:
73-
log_error(f"RevEng.AI | Error getting binary analysis status: {str(ex)}")
86+
log_error(
87+
f"RevEng.AI | Network error while monitoring analysis [Binary ID: {bid}] [Analysis ID: {aid}]: {ex}"
88+
)
7489
except Exception as ex:
75-
log_error(f"RevEng.AI | Unexpected error during status check: {str(ex)}")
90+
log_error(
91+
f"RevEng.AI | Unexpected error while monitoring analysis [Binary ID: {bid}] [Analysis ID: {aid}]: {ex}"
92+
)
7693

7794
self.stop()
7895

79-
self._current_timer = Timer(30, _worker, args=(binary_view, binary_id, analysis_id))
96+
self._current_timer = Timer(
97+
30, _worker, args=(binary_view, binary_id, analysis_id)
98+
)
8099
self._current_timer.start()
81100
log_info(
82101
f"RevEng.AI | Started periodic status check for: {basename(binary_view.file.filename)} [Binary ID: {binary_id}] [Analysis ID: {analysis_id}]"
83-
)
102+
)

‎tests/unit/monitoring/test_process_binary_monitor.py‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,74 @@ def fake_timer(interval, function, args=()):
5757

5858
assert len(timers) == before + 1
5959
checker.sync_service.sync_analysis_data.assert_not_called()
60+
61+
62+
@pytest.mark.parametrize("status", ["Uploaded", "Queued", "Processing"])
63+
def test_worker_reschedules_for_in_progress_statuses(mocker, status):
64+
timers = []
65+
66+
def fake_timer(interval, function, args=()):
67+
timers.append((interval, function, args))
68+
return MagicMock()
69+
70+
mocker.patch.object(pbm_mod, "Timer", side_effect=fake_timer)
71+
api = mocker.patch.object(pbm_mod.revengai, "AnalysesCoreApi").return_value
72+
api.get_analysis_status.return_value.data.analysis_status = status
73+
74+
checker = pbm_mod.PeriodicChecker(MagicMock())
75+
checker.sync_service = MagicMock()
76+
77+
checker.start_checking(_bv(), analysis_id=2, binary_id=1, callback=MagicMock())
78+
before = len(timers)
79+
timers[-1][1](*timers[-1][2])
80+
81+
assert len(timers) == before + 1
82+
checker.sync_service.sync_analysis_data.assert_not_called()
83+
84+
85+
def test_worker_treats_error_status_as_terminal_failure(mocker):
86+
timers = []
87+
88+
def fake_timer(interval, function, args=()):
89+
timers.append((interval, function, args))
90+
return MagicMock()
91+
92+
mocker.patch.object(pbm_mod, "Timer", side_effect=fake_timer)
93+
api = mocker.patch.object(pbm_mod.revengai, "AnalysesCoreApi").return_value
94+
api.get_analysis_status.return_value.data.analysis_status = "Error"
95+
96+
checker = pbm_mod.PeriodicChecker(MagicMock())
97+
checker.sync_service = MagicMock()
98+
callback = MagicMock()
99+
100+
checker.start_checking(_bv(), analysis_id=2, binary_id=1, callback=callback)
101+
before = len(timers)
102+
timers[-1][1](*timers[-1][2])
103+
104+
assert len(timers) == before
105+
callback.assert_not_called()
106+
checker.sync_service.sync_analysis_data.assert_not_called()
107+
108+
109+
def test_worker_treats_unrecognised_status_as_terminal_failure(mocker):
110+
timers = []
111+
112+
def fake_timer(interval, function, args=()):
113+
timers.append((interval, function, args))
114+
return MagicMock()
115+
116+
mocker.patch.object(pbm_mod, "Timer", side_effect=fake_timer)
117+
api = mocker.patch.object(pbm_mod.revengai, "AnalysesCoreApi").return_value
118+
api.get_analysis_status.return_value.data.analysis_status = "Weird"
119+
120+
checker = pbm_mod.PeriodicChecker(MagicMock())
121+
checker.sync_service = MagicMock()
122+
callback = MagicMock()
123+
124+
checker.start_checking(_bv(), analysis_id=2, binary_id=1, callback=callback)
125+
before = len(timers)
126+
timers[-1][1](*timers[-1][2])
127+
128+
assert len(timers) == before
129+
callback.assert_not_called()
130+
checker.sync_service.sync_analysis_data.assert_not_called()

‎tests/unit/sdk/test_sdk_schemas.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,12 @@ def test_task_status_covers_plugin_state_machine():
223223
assert {"UNINITIALISED", "COMPLETED", "FAILED"} <= set(TaskStatus.__members__)
224224

225225

226+
def test_status_input_covers_analysis_state_machine():
227+
assert {"UPLOADED", "QUEUED", "PROCESSING", "COMPLETE", "ERROR"} <= set(
228+
revengai.StatusInput.__members__
229+
)
230+
231+
226232
def test_binary_search_result_has_plugin_fields():
227233
assert {
228234
"binary_id",

0 commit comments

Comments
 (0)