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

+120 -1
View File
@@ -16,7 +16,7 @@ import pytest
from conftest import _SHIELD_DIR
from entity_stublib import install, load_entity_modules
from nsh.connection import ShieldConnectionManager
from nsh.const import TIVIMATE_PACKAGE
from nsh.const import APP_LABEL_EXIT_MARKER, TIVIMATE_PACKAGE
@pytest.fixture(autouse=True)
@@ -1619,6 +1619,8 @@ class _LabelPoller(FakeAdbPoller):
super().__init__()
self.pm_dumps: dict[str, str] = {}
self.dumps: list[str] = []
#: t57: True = the transport never delivered output (a FAILED fetch)
self.shell_failure = False
@property
def connected(self):
@@ -1644,12 +1646,25 @@ class _LabelPoller(FakeAdbPoller):
return []
async def shell(self, cmd: str) -> str | None:
"""Model BOTH real outcomes of a label dump (t57).
A package in ``pm_dumps`` answers with its dump. A package that is NOT
there answers with the exit marker and nothing else — the shape a real
transport produces for "the command ran and this package has no
label". ``shell_failure`` turns every dump into the OTHER real shape:
the transport never delivered output at all (``adb_client.shell``
returns None when it is not connected or the command raised).
"""
self.dumps.append(cmd)
import shlex
if self.shell_failure:
return None
for package, dump in self.pm_dumps.items():
if shlex.quote(package) in cmd or package in cmd:
return dump
if APP_LABEL_EXIT_MARKER in cmd:
return f"{APP_LABEL_EXIT_MARKER}0\n"
return None
@@ -1753,6 +1768,110 @@ class TestCurrentAppNameEndToEndT55:
assert sensor.native_value == "com.example.unknown"
assert "com.example.unknown" in manager._app_labels
@pytest.mark.parametrize("blank", [None, ""], ids=["none", "empty"])
async def test_a_dump_with_no_output_is_a_failure_not_an_answer(
self, modules, blank
):
"""t59 F1 (HIGH): the live symptom, driven through the SENSOR.
``adb_client.shell`` swallows its error and returns None, and a real
dump can also come back as an empty string. Neither is the device
saying "this package has no label": reading either as an answer stamped
a 24 h definitive negative after ONE transient failure, so the sensor
stayed on the package id, exactly one dump was ever attempted, and a
later GOOD dump could not recover the label. Both shapes are driven
here — the pre-fix tree fails this at the second cycle.
"""
player, manager, sensor, poller = self._wire(
modules, "ar.tvplayer.tv", {"ar.tvplayer.tv": "application-label: 'TiviMate'"}
)
async def _blank(_cmd):
poller.dumps.append(_cmd)
return blank
# cycle 1: the transport delivered nothing → a FAILED fetch
poller.shell = _blank
assert sensor.native_value == "ar.tvplayer.tv"
await manager._watchdog_cycle(5.0)
await asyncio.sleep(0)
assert "ar.tvplayer.tv" not in manager._app_labels # nothing stamped
assert sensor.native_value == "ar.tvplayer.tv"
# the next render re-enqueues, and the retry is what recovers the label
assert manager.app_label("ar.tvplayer.tv") is None
assert manager.app_label_requests == {"ar.tvplayer.tv"}
dump_count = len([d for d in poller.dumps if "pm dump" in d])
real_shell = _LabelPoller.shell.__get__(poller, _LabelPoller)
poller.shell = real_shell
await manager._watchdog_cycle(5.0)
await asyncio.sleep(0)
# a later GOOD dump recovers it — the pre-fix cache made this impossible
assert len([d for d in poller.dumps if "pm dump" in d]) == dump_count + 1
assert sensor.native_value == "TiviMate"
assert sensor.extra_state_attributes["app_name"] == "TiviMate"
assert player.app_name == "TiviMate"
@pytest.mark.parametrize("blank", [None, ""], ids=["none", "empty"])
async def test_the_player_recovers_the_label_after_a_failed_fetch(
self, modules, blank
):
"""Same defect, driven through ``media_player.app_name`` (t59 F1).
The sensor and the player read the same chain, but they are two
different surfaces and the reviewer's reproduction observed BOTH
stuck on the package id — so both are pinned.
"""
player, manager, sensor, poller = self._wire(
modules, "com.netflix.ninja", {"com.netflix.ninja": "application-label: 'Netflix'"}
)
async def _blank(_cmd):
poller.dumps.append(_cmd)
return blank
poller.shell = _blank
assert player.app_name == "com.netflix.ninja"
await manager._watchdog_cycle(5.0)
await asyncio.sleep(0)
assert "com.netflix.ninja" not in manager._app_labels
assert player.app_name == "com.netflix.ninja"
assert manager.app_label("com.netflix.ninja") is None # re-enqueued
poller.shell = _LabelPoller.shell.__get__(poller, _LabelPoller)
await manager._watchdog_cycle(5.0)
await asyncio.sleep(0)
assert player.app_name == "Netflix"
assert sensor.native_value == "Netflix"
async def test_a_genuinely_label_less_package_is_still_negatively_cached(
self, modules
):
"""The other half: a dump that RAN and found no label stays definitive.
This is what keeps the t24 F1 rule closed — a label-less package costs
zero further round trips. The marker in the output is what separates
this from the failed fetch above.
"""
player, manager, sensor, poller = self._wire(modules, "com.oem.blank", {})
assert sensor.native_value == "com.oem.blank"
await manager._watchdog_cycle(5.0)
await asyncio.sleep(0)
assert "com.oem.blank" in manager._app_labels # a definitive negative
assert manager.app_label("com.oem.blank") is None
assert manager.app_label_requests == set() # no re-enqueue, ever
dumps = [d for d in poller.dumps if "pm dump" in d]
await manager._watchdog_cycle(5.0)
await asyncio.sleep(0)
assert [d for d in poller.dumps if "pm dump" in d] == dumps # zero more
async def test_the_fetch_targets_the_app_on_screen_not_the_previous_one(self, modules):
player, manager, sensor, poller = self._wire(
modules,