fix(connection): a label dump that returned nothing is a FAILED fetch (t57 UNIT 0)

t59 F1 (HIGH). `ShieldAdbPoller.shell` swallows its error and answers None
(adb_client.py:144-155), and `_fetch_app_label` read that as "the dump
ran and found no label" → `cache_app_label(pkg, None)` → `('', stamp,
resolved=True)`. That is a DEFINITIVE negative for APP_LABEL_TTL = 24 h,
so after ONE transient failure `app_label()` never re-enqueued, exactly
one `pm dump` was ever attempted, and a later good dump could not
recover the label — `sensor.native_value` and `media_player.app_name`
stayed on the package id for every later render.

The dump now carries an exit marker (`; echo __NSH_EXIT:$?`), so the
three outcomes are distinguishable:

* output with an `application-label` → cache the label;
* output that ran and carried none (the marker is still there) → cache
  the empty string, the definitive negative that keeps t24 F1's
  zero-round-trip rule for a genuinely label-less package;
* no output at all (`None`, or blank) → cache NOTHING and let the next
  render re-enqueue, logged at debug.

The `resolved` flag goes with it: `cache_app_label` was its only writer
and always passed True, so an entry in `_app_labels` is now simply "a
fetch RAN" and the dead third field (`(label, stamp, True)`) is gone.

Tests drive the FakePoller-returns-None AND returns-'' shapes through
the sensor and through `media_player.app_name`, plus the label-less
package that must still be negatively cached. The stub poller now models
both real outcomes of a dump (an answer, or a transport that delivered
nothing) instead of conflating them — which is why the old tests could
only pin the raising-shell branch that already worked. 4 of the new
tests fail on the pre-fix tree, showing `('', stamp, True)` stamped.

Also in this commit:
- t59 F3: running_apps.py set `app["state"] = "paused"` twice — one line.
- t59 F4: no captured fixture contains state=6, so the "the Shield's own
  sessions report 6 like stock Android" wording in README and the
  media_session/media_player docstrings is reworded as INFERRED from the
  `state=PlaybackState {state=N, …}` line shape. A `git notes` entry on
  07682ff records the same correction against the t55 commit body.
This commit is contained in:
James Mackay committed 2026-10-12 07:21:34 +01:00
1 parent ca3fdec218
commit 9461d69469
5 files changed
+183 -32

No files matched your search

+51 -28
View File
@@ -36,6 +36,7 @@ from .const import (
ADB_RECONNECT_BACKOFF_CAP,
ADB_RECONNECT_BACKOFF_START,
ADB_RECONNECT_JITTER,
APP_LABEL_EXIT_MARKER,
APP_LABEL_TTL,
CONF_LOGCAT_EVENTS,
LOGCAT_SUPPRESS_WINDOW,
@@ -206,7 +207,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, bool]] = {}
# An entry means "a fetch RAN" (t57 UNIT 0): a tuple whose label is
# "" is a definitive negative, and a fetch that could not run is not
# in here at all — so the flag t55 carried to say so was dead weight
# (its only writer always passed True) and is gone.
self._app_labels: dict[str, tuple[str, float]] = {}
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.
@@ -578,16 +583,14 @@ class ShieldConnectionManager:
return None
cached = self._app_labels.get(package)
if cached is not None:
label, stamp, resolved = cached
label, stamp = cached
if self._clock() - stamp <= APP_LABEL_TTL:
# empty label = negative cache (t24 F1): the pm dump ran and
# empty label = negative cache (t24 F1): the pm dump RAN and
# 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
# A fetch that did not run is not cached at all (t57 UNIT 0),
# so there is no "unresolved" shape to fall through on.
return label or None
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
):
@@ -595,15 +598,15 @@ class ShieldConnectionManager:
return None
def cache_app_label(self, package: str, label: str | None) -> None:
"""Seed the label cache with a definitive result (t55).
"""Record a label fetch that RAN, and what it found (t55, t57).
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.
The cache's only writer. ``None``/"" is the negative result devices
genuinely report for label-less packages, and it is definitive: no
further ``pm dump`` for ``APP_LABEL_TTL``. A fetch that did NOT run
must not call this — that was the live defect (see
``_fetch_app_label``).
"""
self._app_labels[package] = (label or "", self._clock(), True)
self._app_labels[package] = (label or "", self._clock())
@property
def app_label_requests(self) -> set[str]:
@@ -634,18 +637,29 @@ class ShieldConnectionManager:
_LOGGER.debug("protocol device info unavailable: %s", err)
async def _fetch_app_label(self, package: str) -> None:
"""One `pm dump` label fetch; stamps ONLY a fetch that really ran (t55).
"""One `pm dump` label fetch; stamps ONLY a fetch that really RAN (t57).
Three outcomes, three postures:
The distinction this method exists for (t59 F1): a FAILED fetch and a
genuinely label-less package are not the same thing, and conflating
them is what kept ``app_name`` on the raw package id forever.
* the dump ran and carries an `application-label` → cache the label;
* 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.
definitive negative (t24 F1: no per-heartbeat round-trip tax for a
label-less package);
* the fetch did NOT run → cache NOTHING, and the next render
re-enqueues. ``ShieldAdbPoller.shell`` answers ``None`` for both
shapes of "did not run" — no connected device, and a command that
raised — so ``None``/blank output is treated as a failure rather
than being read as "the dump ran and found no label". Treating it
as the latter stamped a 24 h definitive negative after ONE transient
failure, and a later good dump could never recover the label.
The dump also carries an exit marker (``; echo __NSH_EXIT:$?``), which
is what keeps the third branch from swallowing a REAL empty answer: if
the command ran and produced nothing, the marker is still there, so
the negative cache happens (zero further round trips). If the output
is blank the command never delivered anything, and we retry.
"""
self._app_label_inflight.add(package)
try:
@@ -661,9 +675,9 @@ class ShieldConnectionManager:
import shlex
dump = await poller.shell(
f"pm dump {shlex.quote(package)} 2>/dev/null | head -300"
f"pm dump {shlex.quote(package)} 2>/dev/null | head -300; "
f"echo {APP_LABEL_EXIT_MARKER}$?"
)
label = parse_application_label(dump or "")
except Exception as err:
_LOGGER.debug(
"app label fetch failed for %s (%s) — will retry on the "
@@ -672,9 +686,18 @@ class ShieldConnectionManager:
err,
)
return
if dump is None or not dump.strip():
# the transport never delivered output: a FAILED fetch, not an
# answer. Caching here is the t55 defect this repairs.
_LOGGER.debug(
"app label fetch for %s returned no output — will retry on "
"the next render",
package,
)
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)
self.cache_app_label(package, parse_application_label(dump))
finally:
self._app_label_inflight.discard(package)
def _on_logcat_app_start(self, package: str) -> None: