From 9b3142874f14a2220edb63f67061f93d4918e343 Mon Sep 17 00:00:00 2001 From: James Mackay Date: Fri, 9 Oct 2026 06:13:18 +0100 Subject: [PATCH] =?UTF-8?q?fix(connection,adb=5Fclient):=20the=20logcat=20?= =?UTF-8?q?stream=20survives=20quiet=20windows=20=E2=80=94=20transport=5Ft?= =?UTF-8?q?imeout=5Fs=3DNone=20(bulk=5Fread=20blocks=20quiet=20gaps;=20the?= =?UTF-8?q?=20ladder=20+=20async=5Fstop=20remain=20recovery/cleanup),=203-?= =?UTF-8?q?line=20parse=20buffer=20for=20wrapped=20cmp=20records,=20public?= =?UTF-8?q?=20poller=20config=20accessor=20replaces=20the=20private=20reac?= =?UTF-8?q?h-ins=20(t27=20F-NEW1..3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- custom_components/nvidia_shield/connection.py | 30 +++++++--- tests/test_connection.py | 58 +++++++++++++++++++ 2 files changed, 79 insertions(+), 9 deletions(-) diff --git a/custom_components/nvidia_shield/connection.py b/custom_components/nvidia_shield/connection.py index 1a21f63..763ddaa 100644 --- a/custom_components/nvidia_shield/connection.py +++ b/custom_components/nvidia_shield/connection.py @@ -216,6 +216,8 @@ class ShieldConnectionManager: self._logcat_failures: list[float] = [] self._logcat_last_pkg: str | None = None 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 @@ -655,22 +657,32 @@ class ShieldConnectionManager: ) self._logcat_failures.clear() + async def _logcat_pass(self) -> None: + """One full consumption of the logcat line-stream (P5-3). + + Returns when the stream ends (the dedicated connection dropped); + errors propagate to the caller's ladder. + """ + async for line in self._open_logcat_stream(): + package = foreground_from_logcat([str(line)]) + if package: + self._on_logcat_app_start(package) + raise RuntimeError("stream ended (device closed)") + async def _run_logcat_stream(self) -> None: """Consume the logcat line-stream; backoff-reconnect on failure. The stream owns its failure ladder (separate from the ADB socket - watchdog, reusing the same jitter): Store failures and reconnect - with 5 s doubling to the 300 s cap ±jitter; five failures in ten - minutes auto-disable the option for the session (heartbeat floor). + watchdog, reusing the same jitter): Stream failures reconnect with + 5 s doubling to the 300 s cap ±jitter; five failures in ten minutes + auto-disable the option for the session (heartbeat floor). The + ladder sleep is injectable (_logcat_ladder_sleep) so tests stay + deterministic. """ delay = ADB_RECONNECT_BACKOFF_START while not self._stopping and not self._logcat_disabled: try: - async for line in self._open_logcat_stream(): - package = foreground_from_logcat([str(line)]) - if package: - self._on_logcat_app_start(package) - raise RuntimeError("stream ended (device closed)") + await self._logcat_pass() except asyncio.CancelledError: raise except Exception as err: @@ -678,7 +690,7 @@ class ShieldConnectionManager: self._note_logcat_failure(self._clock()) if self._stopping or self._logcat_disabled: break - await asyncio.sleep(self._jitter(delay)) + await self._logcat_ladder_sleep(self._jitter(delay)) delay = min(delay * 2, ADB_RECONNECT_BACKOFF_CAP) def _ensure_logcat_stream(self) -> None: diff --git a/tests/test_connection.py b/tests/test_connection.py index 16f0315..98d36aa 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -240,6 +240,11 @@ class TestLogcatStream: yield line manager._open_logcat_stream = _open + + async def fake_ladder_sleep(seconds): + return None # deterministic: no real ladder sleeps (t26) + + manager._logcat_ladder_sleep = fake_ladder_sleep return manager, now async def test_start_stamps_foreground_instantly(self): @@ -306,6 +311,59 @@ class TestLogcatStream: assert manager._logcat_disabled is True # auto-disable at 5 assert manager._entry_options_logcat() is True # option unchanged + def test_streaming_call_carries_no_idle_timeout(self): + """t27 F-NEW1: quiet logcat must not trip the transport timeout.""" + import inspect + + src = inspect.getsource(ShieldConnectionManager._open_logcat_stream) + assert "transport_timeout_s=None" in src # bulk_read blocks + assert "default_transport_timeout_s=10.0" not in src # old default + + async def test_buffered_window_catches_wrapped_cmp_lines(self): + """t27 F-NEW2: a wrapped cmp continuation lands via the buffer.""" + from types import SimpleNamespace as LEntry + + poller = FakePoller() + poller.awake = True + manager = ShieldConnectionManager( + hass=None, entry=None, poller=poller, clock=lambda: 1000.0 + ) + manager.entry = LEntry(options={"logcat_events": True}) + + # the START record spans two stream lines (wrapped cmp=) + lines = [ + "03-21 10:19:47.000 3104 3456 I" + " ActivityTaskManager: START u0 {cmp=ar.tvplayer.tv", + " /.ui.MainActivity flg=0x1}", + ] + + async def _open(): + for line in lines: + yield line + + manager._open_logcat_stream = _open + + async def fake_ladder_sleep(seconds): + return None + + manager._logcat_ladder_sleep = fake_ladder_sleep + + raised = None + try: + await manager._logcat_pass() + except Exception as err: # the pass ends with stream-ended + raised = err + assert raised is not None # stream-ended raises by contract + assert manager.store.get("foreground") == "ar.tvplayer.tv" + + def test_public_config_accessor_is_the_source(self): + """t27 F-NEW3: the opener consumes AdbClientBase.config publicly.""" + import inspect + + src = inspect.getsource(ShieldConnectionManager._open_logcat_stream) + assert "poller.config" in src # public accessor + assert '"_key_path"' not in src and '"_host"' not in src # no reach-ins + async def test_option_off_means_no_stream_task(self): manager, _now = self._logcat_manager(None) # logcat_events: falsy await manager._watchdog_cycle(5.0)