Skip to content

Commit adcfcca

Browse files
Harden TUI status refresh resilience
1 parent a2ba62a commit adcfcca

2 files changed

Lines changed: 118 additions & 16 deletions

File tree

mythic_vibe_cli/tui/app.py

Lines changed: 58 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,7 @@ def __init__(self, root: Path) -> None:
773773
# Warm-starts from any existing entries so the panel populates
774774
# immediately, but only counts truly-new events as "live".
775775
self._event_reader = EventTailReader(event_log_path_for(self.root))
776+
self._refresh_timer: Any | None = None
776777

777778
def compose(self) -> ComposeResult:
778779
yield Header(show_clock=True)
@@ -789,7 +790,14 @@ def compose(self) -> ComposeResult:
789790

790791
def on_mount(self) -> None:
791792
self._refresh_panels()
792-
self.set_interval(REFRESH_INTERVAL_SECONDS, self._refresh_panels)
793+
self._refresh_timer = self.set_interval(REFRESH_INTERVAL_SECONDS, self._refresh_panels)
794+
795+
def on_unmount(self) -> None:
796+
timer = self._refresh_timer
797+
self._refresh_timer = None
798+
stop = getattr(timer, "stop", None)
799+
if callable(stop):
800+
stop()
793801

794802
def action_refresh_now(self) -> None:
795803
self._refresh_panels()
@@ -812,24 +820,58 @@ def action_open_drift(self) -> None:
812820
self.app.push_screen(DriftScreen(self.root))
813821

814822
def _refresh_panels(self) -> None:
815-
data = build_status_data(self.root)
816-
loop_nav_data = build_loop_navigator_data(self.root)
817-
artifact_data = build_artifact_viewer_data(self.root, loop_nav_data.current_phase)
818-
packet_data = build_packet_viewer_data(self.root)
819-
diagnostics = self._event_reader.poll()
823+
try:
824+
data = build_status_data(self.root)
825+
status_text = _format_status_bar(data)
826+
footer_text = _format_footer_line(data)
827+
except Exception as exc: # noqa: BLE001 - TUI must degrade per panel
828+
status_text = _format_panel_error("Status", exc)
829+
footer_text = "Last refresh: unavailable"
830+
831+
try:
832+
loop_nav_data = build_loop_navigator_data(self.root)
833+
loop_text = _format_loop_navigator(loop_nav_data)
834+
current_phase = loop_nav_data.current_phase
835+
except Exception as exc: # noqa: BLE001 - TUI must degrade per panel
836+
loop_text = _format_panel_error("Loop", exc)
837+
current_phase = ""
838+
839+
try:
840+
artifact_data = build_artifact_viewer_data(self.root, current_phase)
841+
artifact_text = _format_artifact_viewer(artifact_data)
842+
artifact_phase = artifact_data.phase or "(none)"
843+
except Exception as exc: # noqa: BLE001 - TUI must degrade per panel
844+
artifact_text = _format_panel_error("Artefacts", exc)
845+
artifact_phase = current_phase or "(none)"
846+
847+
try:
848+
packet_data = build_packet_viewer_data(self.root)
849+
packet_text = _format_packet_viewer(packet_data)
850+
packet_title = f"Packet ({packet_data.packet_id})" if packet_data.packet_id else "Packet"
851+
except Exception as exc: # noqa: BLE001 - TUI must degrade per panel
852+
packet_text = _format_panel_error("Packet", exc)
853+
packet_title = "Packet"
854+
855+
try:
856+
diagnostics = self._event_reader.poll()
857+
diagnostics_text = _format_diagnostics_panel(diagnostics)
858+
except Exception as exc: # noqa: BLE001 - TUI must degrade per panel
859+
diagnostics_text = _format_panel_error("Diagnostics", exc)
860+
820861
self._loop_nav_widget.border_title = "Loop"
821862
self._events_widget.border_title = "Diagnostics"
822-
artifact_phase = artifact_data.phase or "(none)"
823863
self._artifact_widget.border_title = f"Artefacts ({artifact_phase})"
824-
self._packet_widget.border_title = (
825-
f"Packet ({packet_data.packet_id})" if packet_data.packet_id else "Packet"
826-
)
827-
self._loop_nav_widget.update(_format_loop_navigator(loop_nav_data))
828-
self._events_widget.update(_format_diagnostics_panel(diagnostics))
829-
self._artifact_widget.update(_format_artifact_viewer(artifact_data))
830-
self._packet_widget.update(_format_packet_viewer(packet_data))
831-
self._status_bar_widget.update(_format_status_bar(data))
832-
self._footer_widget.update(_format_footer_line(data))
864+
self._packet_widget.border_title = packet_title
865+
self._loop_nav_widget.update(loop_text)
866+
self._events_widget.update(diagnostics_text)
867+
self._artifact_widget.update(artifact_text)
868+
self._packet_widget.update(packet_text)
869+
self._status_bar_widget.update(status_text)
870+
self._footer_widget.update(footer_text)
871+
872+
873+
def _format_panel_error(label: str, exc: BaseException) -> str:
874+
return f"[yellow]{label} unavailable[/yellow]\n[dim]{type(exc).__name__}: {exc}[/dim]"
833875

834876

835877
class MythicTuiApp(App):

tests/test_tui.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import time
1717
import unittest
1818
from pathlib import Path
19+
from unittest import mock
1920

2021

2122
textual_unavailable = False
@@ -264,6 +265,65 @@ async def run_test() -> None:
264265
# Should complete without raising.
265266
asyncio.run(run_test())
266267

268+
def test_status_screen_degrades_when_status_data_refresh_fails(self) -> None:
269+
from mythic_vibe_cli.tui.app import MythicTuiApp
270+
271+
async def run_test() -> tuple[str, str]:
272+
with tempfile.TemporaryDirectory() as tmp:
273+
app = MythicTuiApp(Path(tmp))
274+
with mock.patch(
275+
"mythic_vibe_cli.tui.app.build_status_data",
276+
side_effect=RuntimeError("state unavailable"),
277+
):
278+
async with app.run_test() as pilot:
279+
await pilot.pause()
280+
status_bar = app.screen.query_one("#status-bar")
281+
footer_widget = app.screen.query_one("#footer-line")
282+
return str(status_bar.render()), str(footer_widget.render())
283+
284+
rendered_bar, rendered_footer = asyncio.run(run_test())
285+
self.assertIn("Status unavailable", rendered_bar)
286+
self.assertIn("state unavailable", rendered_bar)
287+
self.assertIn("Last refresh: unavailable", rendered_footer)
288+
289+
def test_status_screen_degrades_when_diagnostics_poll_fails(self) -> None:
290+
from mythic_vibe_cli.tui.app import MythicTuiApp
291+
292+
async def run_test() -> str:
293+
with tempfile.TemporaryDirectory() as tmp:
294+
app = MythicTuiApp(Path(tmp))
295+
with mock.patch(
296+
"mythic_vibe_cli.tui.app.EventTailReader.poll",
297+
side_effect=OSError("event log unreadable"),
298+
):
299+
async with app.run_test() as pilot:
300+
await pilot.pause()
301+
panel = app.screen.query_one("#events-panel")
302+
return str(panel.render())
303+
304+
rendered = asyncio.run(run_test())
305+
self.assertIn("Diagnostics unavailable", rendered)
306+
self.assertIn("event log unreadable", rendered)
307+
308+
def test_status_screen_stops_refresh_timer_on_unmount(self) -> None:
309+
from mythic_vibe_cli.tui.app import StatusScreen
310+
311+
class FakeTimer:
312+
def __init__(self) -> None:
313+
self.stopped = False
314+
315+
def stop(self) -> None:
316+
self.stopped = True
317+
318+
with tempfile.TemporaryDirectory() as tmp:
319+
screen = StatusScreen(Path(tmp))
320+
timer = FakeTimer()
321+
screen._refresh_timer = timer
322+
screen.on_unmount()
323+
324+
self.assertTrue(timer.stopped)
325+
self.assertIsNone(screen._refresh_timer)
326+
267327

268328
@unittest.skipIf(textual_unavailable, "textual not installed")
269329
class SlashPickerTests(unittest.TestCase):

0 commit comments

Comments
 (0)