fix(connection,adb_client): the logcat stream survives quiet windows — transport_timeout_s=None (bulk_read blocks quiet gaps; the ladder + async_stop remain recovery/cleanup), 3-line parse buffer for wrapped cmp records, public poller config accessor replaces the private reach-ins (t27 F-NEW1..3)
This commit is contained in:
1 parent
27715f6932
commit
9b3142874f
2 files changed
+79
-9
No files matched your search
@@ -216,6 +216,8 @@ class ShieldConnectionManager:
|
|||||||
self._logcat_failures: list[float] = []
|
self._logcat_failures: list[float] = []
|
||||||
self._logcat_last_pkg: str | None = None
|
self._logcat_last_pkg: str | None = None
|
||||||
self._logcat_last_stamp: float = -1e9
|
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()
|
self._app_label_requests: set[str] = set()
|
||||||
# Injectable clock keeps the TTL expiry tests deterministic.
|
# Injectable clock keeps the TTL expiry tests deterministic.
|
||||||
self._clock = clock
|
self._clock = clock
|
||||||
@@ -655,22 +657,32 @@ class ShieldConnectionManager:
|
|||||||
)
|
)
|
||||||
self._logcat_failures.clear()
|
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:
|
async def _run_logcat_stream(self) -> None:
|
||||||
"""Consume the logcat line-stream; backoff-reconnect on failure.
|
"""Consume the logcat line-stream; backoff-reconnect on failure.
|
||||||
|
|
||||||
The stream owns its failure ladder (separate from the ADB socket
|
The stream owns its failure ladder (separate from the ADB socket
|
||||||
watchdog, reusing the same jitter): Store failures and reconnect
|
watchdog, reusing the same jitter): Stream failures reconnect with
|
||||||
with 5 s doubling to the 300 s cap ±jitter; five failures in ten
|
5 s doubling to the 300 s cap ±jitter; five failures in ten minutes
|
||||||
minutes auto-disable the option for the session (heartbeat floor).
|
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
|
delay = ADB_RECONNECT_BACKOFF_START
|
||||||
while not self._stopping and not self._logcat_disabled:
|
while not self._stopping and not self._logcat_disabled:
|
||||||
try:
|
try:
|
||||||
async for line in self._open_logcat_stream():
|
await self._logcat_pass()
|
||||||
package = foreground_from_logcat([str(line)])
|
|
||||||
if package:
|
|
||||||
self._on_logcat_app_start(package)
|
|
||||||
raise RuntimeError("stream ended (device closed)")
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
@@ -678,7 +690,7 @@ class ShieldConnectionManager:
|
|||||||
self._note_logcat_failure(self._clock())
|
self._note_logcat_failure(self._clock())
|
||||||
if self._stopping or self._logcat_disabled:
|
if self._stopping or self._logcat_disabled:
|
||||||
break
|
break
|
||||||
await asyncio.sleep(self._jitter(delay))
|
await self._logcat_ladder_sleep(self._jitter(delay))
|
||||||
delay = min(delay * 2, ADB_RECONNECT_BACKOFF_CAP)
|
delay = min(delay * 2, ADB_RECONNECT_BACKOFF_CAP)
|
||||||
|
|
||||||
def _ensure_logcat_stream(self) -> None:
|
def _ensure_logcat_stream(self) -> None:
|
||||||
|
|||||||
@@ -240,6 +240,11 @@ class TestLogcatStream:
|
|||||||
yield line
|
yield line
|
||||||
|
|
||||||
manager._open_logcat_stream = _open
|
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
|
return manager, now
|
||||||
|
|
||||||
async def test_start_stamps_foreground_instantly(self):
|
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._logcat_disabled is True # auto-disable at 5
|
||||||
assert manager._entry_options_logcat() is True # option unchanged
|
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):
|
async def test_option_off_means_no_stream_task(self):
|
||||||
manager, _now = self._logcat_manager(None) # logcat_events: falsy
|
manager, _now = self._logcat_manager(None) # logcat_events: falsy
|
||||||
await manager._watchdog_cycle(5.0)
|
await manager._watchdog_cycle(5.0)
|
||||||
|
|||||||
Reference in new issue
Block a user