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:
1 parent
ccde3cff71
commit
0c7e405108
4 files changed
+200
-35
No files matched your search
@@ -177,11 +177,18 @@ class TestAppLabelCache:
|
||||
assert manager.app_label("com.unknown.app") is None
|
||||
await manager._watchdog_cycle(5.0)
|
||||
|
||||
# still None (next tier), no crash — falls through cleanly
|
||||
assert manager.app_label("com.unknown.app") is not None or True
|
||||
# the dump RAN and carried no label: definitive negative, and the
|
||||
# caller's next tier (the raw package) takes over — still None here,
|
||||
# never a crash or a fabricated label
|
||||
assert manager.app_label("com.unknown.app") is None
|
||||
|
||||
async def test_label_less_package_negatively_cached_no_repeat_dump(self):
|
||||
"""t26 F1: unresolvable packages cost at most one pm dump ever."""
|
||||
async def test_label_less_package_is_a_definitive_negative(self):
|
||||
"""t24 F1: a label-less package's ANSWER costs one pm dump, not one/cycle.
|
||||
|
||||
The old code additionally stamped this answer on FAILURE paths; t55
|
||||
separates the two (see the retry tests below), but the negative cache
|
||||
for a dump that really ran stays — the round-trip tax must not return.
|
||||
"""
|
||||
manager, _t = self._manager_with_dump()
|
||||
manager._poller.pm_dumps["com.unknown.app"] = "no label line here"
|
||||
|
||||
@@ -201,6 +208,94 @@ class TestAppLabelCache:
|
||||
assert len(manager._poller.shell_calls) == first_dump_count
|
||||
assert manager.app_label("com.unknown.app") is None # falls through
|
||||
|
||||
async def test_failed_fetch_is_not_cached_and_retries(self):
|
||||
"""t55 root cause: a fetch that could NOT run must not become a miss.
|
||||
|
||||
The live defect was a metadata fetch failing and the failure being
|
||||
written as ("", stamp) — an eternal "no label" for the whole 24 h TTL,
|
||||
which left app_name showing the raw package id with no way back. Here
|
||||
the failed fetch stamps nothing, the next render re-enqueues, and the
|
||||
retry resolves the real label.
|
||||
"""
|
||||
manager, _t = self._manager_with_dump()
|
||||
real_shell = manager._poller.shell
|
||||
calls: list[str] = []
|
||||
|
||||
async def _flaky(cmd):
|
||||
calls.append(cmd)
|
||||
if "pm dump" in cmd:
|
||||
raise RuntimeError("adb shell blew up")
|
||||
return await real_shell(cmd)
|
||||
|
||||
manager._poller.shell = _flaky
|
||||
|
||||
assert manager.app_label("ar.tvplayer.tv") is None # enqueue
|
||||
await manager._watchdog_cycle(5.0)
|
||||
import asyncio
|
||||
|
||||
await asyncio.sleep(0)
|
||||
|
||||
# the fetch ran and failed → NOTHING is stamped as an answer
|
||||
assert manager._app_labels.get("ar.tvplayer.tv") is None
|
||||
assert [c for c in calls if "pm dump" in c]
|
||||
|
||||
# the next render enqueues again (the miss is retried, not silenced)
|
||||
assert manager.app_label("ar.tvplayer.tv") is None
|
||||
assert manager.app_label_requests == {"ar.tvplayer.tv"}
|
||||
|
||||
# the retry reaches the device and resolves the label
|
||||
manager._poller.shell = real_shell
|
||||
await manager._watchdog_cycle(5.0)
|
||||
await asyncio.sleep(0)
|
||||
assert manager.app_label("ar.tvplayer.tv") == "TiviMate"
|
||||
|
||||
async def test_shell_failure_is_not_cached_and_retries(self):
|
||||
manager, _t = self._manager_with_dump()
|
||||
manager._poller.pm_dumps["ar.tvplayer.tv"] = "application-label: 'TiviMate'"
|
||||
|
||||
async def _boom(_cmd):
|
||||
raise RuntimeError("adb shell blew up")
|
||||
|
||||
manager._poller.shell = _boom
|
||||
manager.app_label("ar.tvplayer.tv")
|
||||
await manager._watchdog_cycle(5.0)
|
||||
import asyncio
|
||||
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert manager._app_labels.get("ar.tvplayer.tv") is None
|
||||
assert manager.app_label_requests == set()
|
||||
assert manager.app_label("ar.tvplayer.tv") is None
|
||||
assert manager.app_label_requests == {"ar.tvplayer.tv"} # re-enqueued
|
||||
|
||||
async def test_in_flight_fetch_is_not_enqueued_twice(self):
|
||||
manager, _t = self._manager_with_dump()
|
||||
|
||||
assert manager.app_label("ar.tvplayer.tv") is None
|
||||
assert manager.app_label("ar.tvplayer.tv") is None # same render loop
|
||||
assert manager.app_label_requests == {"ar.tvplayer.tv"}
|
||||
assert manager.app_label_inflight == set()
|
||||
|
||||
async def test_one_fetch_per_cycle_even_under_a_burst(self):
|
||||
manager, _t = self._manager_with_dump()
|
||||
manager._poller.pm_dumps["com.a"] = "application-label: 'A'"
|
||||
manager._poller.pm_dumps["com.b"] = "application-label: 'B'"
|
||||
|
||||
manager.app_label("com.a")
|
||||
manager.app_label("com.b")
|
||||
await manager._watchdog_cycle(5.0)
|
||||
await manager._watchdog_cycle(5.0)
|
||||
import asyncio
|
||||
|
||||
await asyncio.sleep(0)
|
||||
|
||||
dumps = [c for c in manager._poller.shell_calls if "pm dump" in c]
|
||||
assert len(dumps) == 2 # one per cycle, both eventually resolved
|
||||
assert sorted([manager.app_label("com.a"), manager.app_label("com.b")]) == [
|
||||
"A",
|
||||
"B",
|
||||
]
|
||||
|
||||
async def test_pm_dump_command_is_shell_quoted(self):
|
||||
"""t26 F2: the package argument is shlex.quote'd (consistent)."""
|
||||
import shlex
|
||||
|
||||
@@ -144,10 +144,7 @@ class TestSource:
|
||||
|
||||
def test_it_uses_the_cached_device_label_when_unmapped(self, modules):
|
||||
player, _served = _player(modules, foreground="com.netflix.ninja")
|
||||
player._manager._app_labels["com.netflix.ninja"] = (
|
||||
"Netflix",
|
||||
player._manager._clock(),
|
||||
)
|
||||
player._manager.cache_app_label("com.netflix.ninja", "Netflix")
|
||||
|
||||
assert player.source == "Netflix"
|
||||
|
||||
|
||||
@@ -1241,7 +1241,7 @@ class TestAppNameMapResolution:
|
||||
player, _s, _i = _cmd_player(modules)
|
||||
player._entry.options["app_name_map"] = {} # no override
|
||||
manager = player._manager
|
||||
manager._app_labels["ar.tvplayer.tv"] = ("TiviMate", manager._clock())
|
||||
manager.cache_app_label("ar.tvplayer.tv", "TiviMate")
|
||||
|
||||
manager._on_current_app("ar.tvplayer.tv")
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
Reference in new issue
Block a user