fix(telegram): treat "never checked" identity as stale on a fresh-boot clock

The identity-refresh TTL used 0.0 as the "never checked" sentinel and
compared it against time.monotonic(). That epoch is arbitrary and starts
near zero on a freshly-booted host, so on CI runners and containers
`monotonic() - 0.0` was itself below the TTL — "never checked" read as
"checked just now" and the first identity refresh was suppressed for the
first 5 minutes of uptime. The stale-handle recovery therefore did nothing
on exactly the machines most likely to be freshly booted.

Invisible on a long-lived dev box (uptime >> TTL); caught by CI.

- Sentinel is now None, meaning never checked and always stale.
- Both TTL comparison sites route through _bot_identity_is_fresh().
- Regression test pins the invariant under a faked 12s-uptime clock.

Verified by re-running the recheck tests against a simulated 3s-uptime
host: green with the fix, and restoring the 0.0 sentinel reproduces the
exact CI failure locally.
This commit is contained in:
teknium1 2026-07-26 08:19:20 -07:00 committed by Teknium
parent 08130a26f6
commit d7488a5579
2 changed files with 45 additions and 4 deletions

View file

@ -759,7 +759,12 @@ class TelegramAdapter(BasePlatformAdapter):
# pointing at the old handle until something calls getMe again. Every
# mention/routing comparison reads _current_bot_username() instead.
self._bot_username_observed: Optional[str] = None
self._bot_identity_checked_at: float = 0.0
# None = never checked. Must NOT be 0.0: these are compared against
# time.monotonic(), whose epoch is arbitrary and on a freshly-booted
# host starts near zero — so a 0.0 sentinel reads as "checked just
# now" and suppresses the first refresh for the first TTL seconds of
# uptime.
self._bot_identity_checked_at: Optional[float] = None
self._bot_identity_refresh_task: Optional[asyncio.Task] = None
# Consecutive heartbeat probes that saw queued updates the running
# poller is not consuming. get_me() can't see this — the send path is
@ -7873,6 +7878,19 @@ class TelegramAdapter(BasePlatformAdapter):
continue
self._note_bot_username(getattr(candidate, "username", None))
def _bot_identity_is_fresh(self) -> bool:
"""True when identity was re-read within the TTL.
``None`` means never checked, which is always stale. Do not fold the
sentinel into ``0.0``: monotonic clocks have an arbitrary epoch that
can legitimately be smaller than the TTL on a freshly-booted host,
which would make "never" look like "just now".
"""
checked_at = getattr(self, "_bot_identity_checked_at", None)
if checked_at is None:
return False
return (time.monotonic() - checked_at) < self._BOT_IDENTITY_TTL_SECONDS
async def _refresh_bot_identity(self, *, force: bool = False) -> None:
"""Re-read the bot's identity from Telegram when the cache may be stale.
@ -7883,8 +7901,7 @@ class TelegramAdapter(BasePlatformAdapter):
bot = self._bot
if bot is None or not callable(getattr(bot, "get_me", None)):
return
now = time.monotonic()
if not force and (now - getattr(self, "_bot_identity_checked_at", 0.0)) < self._BOT_IDENTITY_TTL_SECONDS:
if not force and self._bot_identity_is_fresh():
return
try:
me = await asyncio.wait_for(bot.get_me(), self._BOT_IDENTITY_PROBE_TIMEOUT)
@ -8047,7 +8064,7 @@ class TelegramAdapter(BasePlatformAdapter):
existing = getattr(self, "_bot_identity_refresh_task", None)
if existing is not None and not existing.done():
return
if (time.monotonic() - getattr(self, "_bot_identity_checked_at", 0.0)) < self._BOT_IDENTITY_TTL_SECONDS:
if self._bot_identity_is_fresh():
return
try:
loop = asyncio.get_running_loop()

View file

@ -1597,3 +1597,27 @@ def test_clean_bot_trigger_text_strips_the_current_handle():
adapter._note_bot_username("new_helper_bot")
assert adapter._clean_bot_trigger_text("@new_helper_bot ship it") == "ship it"
def test_identity_freshness_does_not_depend_on_host_uptime(monkeypatch):
"""A never-checked identity is stale even when monotonic() is near zero.
time.monotonic() has an arbitrary epoch: on a freshly-booted host (CI
runners, containers) it starts near 0. A 0.0 "never checked" sentinel
therefore reads as "checked just now" for the first TTL seconds of
uptime, suppressing the very first identity refresh so the stale-handle
recovery silently did nothing on exactly the machines most likely to be
freshly booted. Caught by CI, invisible on a long-lived dev box.
"""
adapter = _make_adapter(require_mention=True)
adapter._bot = _IdentityBot(cached="old_helper_bot", server="new_helper_bot")
# Simulate a host that booted 12 seconds ago.
monkeypatch.setattr(
"plugins.platforms.telegram.adapter.time.monotonic", lambda: 12.0
)
assert adapter._bot_identity_is_fresh() is False
adapter._note_bot_username("new_helper_bot")
assert adapter._bot_identity_is_fresh() is True