fix(connection): a failed app-label fetch is retried, not cached as a miss (t55)

The Current App sensor and media_player.app_name showed a package id in
the live run. Root cause was in the manifest-label tier: `_fetch_app_label`
returned early (no poller, ADB down) or logged and returned on a shell
error WITHOUT touching the cache, but `app_label` had already put the
package in `_app_label_requests`; the drain then threw the request away,
so a render between the failure and the next cycle re-enqueued — while
nothing distinguished "the device answered, this package has no label"
from "we never got an answer". Nothing was visibly retried, and the label
tier could stay silent indefinitely.

The cache is now (label, stamp, resolved) and records the FETCH, not the
wishful result:

  * a fetch that RAN caches its outcome for APP_LABEL_TTL — the label, or
    the empty string for a genuinely label-less package. Definitive: no
    re-enqueue, so the t24 F1 per-heartbeat round-trip tax stays closed.
  * a fetch that could NOT run stamps nothing, logs at debug (so a
    package id in the UI is diagnosable), and the next render retries.
  * a queued or in-flight fetch is not enqueued twice, so a burst of
    renders still costs one `pm dump` per watchdog cycle.

`cache_app_label()` is the one public writer of the cache (tests and any
future out-of-band reader no longer poke the private tuple), and
`app_label_requests`/`app_label_inflight` expose the queue for tests.

The old 2-tuple seeds in the label tests move to `cache_app_label`.
This commit is contained in:
James Mackay committed 2026-10-12 06:53:09 +01:00
1 parent ccde3cff71
commit 0c7e405108
4 files changed
+200 -35

No files matched your search

+99 -26
View File
@@ -206,7 +206,11 @@ class ShieldConnectionManager:
# P5-1: device manifest labels — package → (label, stamp). Long-TTL
# (APP_LABEL_TTL) cache resolved via `pm dump` on demand, one
# package per watchdog cycle so a burst never blocks the watchdog.
self._app_labels: dict[str, tuple[str, float]] = {}
self._app_labels: dict[str, tuple[str, float, bool]] = {}
self._app_label_requests: set[str] = set()
# t55: packages whose label fetch is queued or in flight — a render
# during the fetch must not enqueue the same package twice.
self._app_label_inflight: set[str] = set()
# P5-3 (C-STREAM): the dedicated logcat line-stream consumer.
# NEVER shares the poller's ADB socket — its own device object and
# connection; CONF_LOGCAT_EVENTS gates it (default off) and the
@@ -221,7 +225,6 @@ class ShieldConnectionManager:
self._logcat_last_stamp: float = -1e9
# Injectable ladder sleep (t26: deterministic tests / future alt-floors)
self._logcat_ladder_sleep = asyncio.sleep
self._app_label_requests: set[str] = set()
# Injectable clock keeps the TTL expiry tests deterministic.
self._clock = clock
self._tasks: list[asyncio.Task] = []
@@ -548,26 +551,70 @@ class ShieldConnectionManager:
return False
def app_label(self, package: str | None) -> str | None:
"""Cached manifest label for a package (P5-1), or None.
"""Cached manifest label for a package (P5-1), or None (t55).
A cache miss enqueues an on-demand `pm dump` fetch — drained one
package per watchdog cycle via the manager task pool, so entity
refreshes and the watchdog are never blocked by a burst. A failed
resolution simply falls through to the next name tier.
refreshes and the watchdog are never blocked by a burst.
What is cached is the FETCH, not the wishful result (t55 root cause):
* a fetch that RAN caches its outcome for ``APP_LABEL_TTL`` — a label
when the manifest has one, and the empty string otherwise. It is
definitive: the device answered, the package simply has no default
label (several system/OEM packages do not). No re-enqueue, so a
permanently label-less package costs ZERO further round trips
(t24 F1 stays closed).
* a fetch that could NOT run (no poller, ADB down, shell error) caches
nothing, so the next render enqueues again — a miss is retried
visibly instead of being stamped as an eternal "no label". This was
the live defect: the old code stored ("", stamp) on every failure
path, which silenced the label tier for the whole 24 h TTL and left
app_name showing the raw package id.
* while a fetch for the package is queued or in flight, the next
render does not enqueue it a second time.
"""
if not package:
return None
cached = self._app_labels.get(package)
if cached is not None:
label, stamp = cached
label, stamp, resolved = cached
if self._clock() - stamp <= APP_LABEL_TTL:
# empty label = negative cache (t24 F1): the pm dump ran and
# found nothing — do not re-enqueue, zero per-heartbeat tax
return label or None
self._app_labels.pop(package, None) # stale — refetch on demand
self._app_label_requests.add(package)
# found nothing — do not re-enqueue, zero per-heartbeat tax.
# An unresolved stamp is a failed fetch: fall through and
# retry rather than serving a miss forever.
if resolved:
return label or None
else:
self._app_labels.pop(package, None) # stale — refetch on demand
if package not in self._app_label_requests and (
package not in self._app_label_inflight
):
self._app_label_requests.add(package)
return None
def cache_app_label(self, package: str, label: str | None) -> None:
"""Seed the label cache with a definitive result (t55).
The ONE writer of ``_app_labels`` other than ``_fetch_app_label``, so
the cache's shape stays private: a caller (tests, or a future
discovery path that read a label out of band) records that a fetch
RAN, whether or not it produced a label. ``None``/"" is the negative
result devices genuinely report for label-less packages.
"""
self._app_labels[package] = (label or "", self._clock(), True)
@property
def app_label_requests(self) -> set[str]:
"""Packages queued for a manifest-label fetch (public, for tests)."""
return set(self._app_label_requests)
@property
def app_label_inflight(self) -> set[str]:
"""Packages whose label fetch is running right now (public, tests)."""
return set(self._app_label_inflight)
async def async_capture_protocol_device_data(self) -> None:
"""Capture (name, manufacturer, model, sw) once (t30 G10)."""
if self._protocol is None or self.protocol_device_data is not None:
@@ -587,24 +634,49 @@ class ShieldConnectionManager:
_LOGGER.debug("protocol device info unavailable: %s", err)
async def _fetch_app_label(self, package: str) -> None:
"""One `pm dump` label fetch; failure falls through silently."""
poller = self._poller
if poller is None or not self.connected:
return
"""One `pm dump` label fetch; stamps ONLY a fetch that really ran (t55).
Three outcomes, three postures:
* the dump ran and carries an `application-label` → cache the label;
* the dump ran and carries none → cache the empty string as a
definitive negative (the t24 F1 rule: no per-heartbeat round-trip
tax for a label-less package);
* the fetch could not run at all (no connected poller, shell error) →
cache NOTHING, so the next render re-enqueues and the miss is
retried instead of being silent forever. Failures are logged at
debug so ``app_name`` falling back to a package id is diagnosable.
"""
self._app_label_inflight.add(package)
try:
import shlex
dump = await poller.shell(
f"pm dump {shlex.quote(package)} 2>/dev/null | head -300"
)
label = parse_application_label(dump or "")
except Exception as err:
_LOGGER.debug("app label fetch failed for %s: %s", package, err)
return
# F1: label-less pm dumps negatively cache too (empty label) so a
# later cycle performs zero additional pm dumps for the package
self._app_labels[package] = (label or "", self._clock())
poller = self._poller
if poller is None or not self.connected:
_LOGGER.debug(
"app label for %s not fetched (ADB not connected) — will "
"retry on the next render",
package,
)
return
try:
import shlex
dump = await poller.shell(
f"pm dump {shlex.quote(package)} 2>/dev/null | head -300"
)
label = parse_application_label(dump or "")
except Exception as err:
_LOGGER.debug(
"app label fetch failed for %s (%s) — will retry on the "
"next render",
package,
err,
)
return
# F1: label-less pm dumps negatively cache too (empty label) so a
# later cycle performs zero additional pm dumps for the package.
self.cache_app_label(package, label)
finally:
self._app_label_inflight.discard(package)
def _on_logcat_app_start(self, package: str) -> None:
"""Stamp FIELD_FOREGROUND instantly from a parsed START (P5-3).
@@ -892,6 +964,7 @@ class ShieldConnectionManager:
self._last_known.clear()
self._app_labels.clear()
self._app_label_requests.clear()
self._app_label_inflight.clear()
if self._poller is not None:
with contextlib.suppress(Exception):
await self._poller.disconnect()