Skip to content

Commit b28bdcf

Browse files
committed
fix(tui): dedupe periodic update toasts and harden loop tests
Address CodeRabbit review: the periodic check loop could re-toast managed-channel and restart notices every interval, so _update_toast now dedupes by notice text per session; add loop-recovery and toast-dedup regression tests; replace coroutine-frame introspection in the scheduler test with observable behavior.
1 parent 19e2e5a commit b28bdcf

2 files changed

Lines changed: 59 additions & 3 deletions

File tree

src/pythinker_code/ui/shell/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -615,6 +615,7 @@ def __init__(
615615
# cache on every repaint (mirrors the footer's git-branch TTL).
616616
self._update_notice_cache: tuple[float, str | None] = (0.0, None)
617617
self._update_toast_shown_version: str | None = None
618+
self._update_toasts_shown: set[str] = set()
618619
self._running_input_handler: Callable[[UserInput], None] | None = None
619620
self._running_interrupt_handler: Callable[[], None] | None = None
620621
self._active_approval_sink: Any | None = None
@@ -2270,6 +2271,12 @@ def _managed_channel_notice(self) -> str | None:
22702271
return format_managed_channel_notice(current_version, latest)
22712272

22722273
def _update_toast(self, notice: str, *, style: str) -> None:
2274+
# The periodic check loop re-surfaces update outcomes every interval;
2275+
# dedupe by exact notice text so each is toasted once per session
2276+
# (a new version produces new text and toasts again).
2277+
if notice in self._update_toasts_shown:
2278+
return
2279+
self._update_toasts_shown.add(notice)
22732280
toast(notice, topic="update", duration=30.0, immediate=True, style=style)
22742281
if self._prompt_session is not None:
22752282
self._prompt_session.invalidate()

tests/ui_and_conv/test_mid_session_update_notice.py

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,20 @@ async def test_suppressed_notice_does_not_toast(
100100
assert shell._update_toast_shown_version is None
101101

102102

103+
def test_update_toast_dedupes_repeated_notices(runtime: Runtime, tmp_path: Path, _toasts):
104+
"""The periodic loop re-surfaces outcomes; each notice text toasts once."""
105+
shell = _make_shell(runtime, tmp_path)
106+
107+
shell._update_toast("Update available: 9.9.9", style="bold")
108+
shell._update_toast("Update available: 9.9.9", style="bold")
109+
shell._update_toast("Update available: 10.0.0", style="bold")
110+
111+
assert [msg for msg, _ in _toasts] == [
112+
"Update available: 9.9.9",
113+
"Update available: 10.0.0",
114+
]
115+
116+
103117
@pytest.mark.asyncio
104118
async def test_periodic_update_check_runs_immediately_then_at_interval(monkeypatch):
105119
checks: list[str] = []
@@ -125,6 +139,34 @@ async def sleep(delay: float) -> None:
125139
]
126140

127141

142+
@pytest.mark.asyncio
143+
async def test_periodic_update_check_recovers_after_failing_check(monkeypatch):
144+
checks: list[str] = []
145+
sleeps: list[float] = []
146+
147+
async def check() -> None:
148+
checks.append("check")
149+
if len(checks) == 1:
150+
raise RuntimeError("transient check failure")
151+
152+
async def sleep(delay: float) -> None:
153+
sleeps.append(delay)
154+
if len(sleeps) == 2:
155+
raise asyncio.CancelledError
156+
157+
monkeypatch.setattr(shell_module.asyncio, "sleep", sleep)
158+
159+
with pytest.raises(asyncio.CancelledError):
160+
await shell_module._periodic_update_check(check)
161+
162+
# The first failure is swallowed (logged) and the loop keeps re-checking.
163+
assert checks == ["check", "check"]
164+
assert sleeps == [
165+
shell_module.AUTO_UPDATE_CHECK_INTERVAL_SECONDS,
166+
shell_module.AUTO_UPDATE_CHECK_INTERVAL_SECONDS,
167+
]
168+
169+
128170
def test_startup_scheduler_uses_periodic_loop_in_all_scheduling_branches(
129171
runtime: Runtime, tmp_path: Path, monkeypatch
130172
):
@@ -133,13 +175,20 @@ def test_startup_scheduler_uses_periodic_loop_in_all_scheduling_branches(
133175
shell = _make_shell(runtime, tmp_path)
134176
scheduled_checks = []
135177

178+
def fake_periodic(check):
179+
scheduled_checks.append(check)
180+
181+
async def _noop() -> None:
182+
return None
183+
184+
return _noop()
185+
136186
def capture(coro):
137-
assert coro.cr_code.co_name == "_periodic_update_check"
138-
assert coro.cr_frame is not None
139-
scheduled_checks.append(coro.cr_frame.f_locals["check"])
140187
coro.close()
141188
return None
142189

190+
monkeypatch.setattr(shell_module, "_periodic_update_check", fake_periodic)
191+
143192
monkeypatch.delenv("PYTHINKER_CLI_NO_AUTO_UPDATE", raising=False)
144193
monkeypatch.setattr(shell, "_start_background_task", capture)
145194

0 commit comments

Comments
 (0)