screen-locker/screen_locker/tests/test_session_stream_loop.py
2026-10-09 19:05:51 +02:00

172 lines
6.5 KiB
Python

"""Tests for _session_stream.run_forever: reconnect backoff and thread control."""
from __future__ import annotations
import logging
from types import SimpleNamespace
from typing import TYPE_CHECKING
import pytest
from screen_locker import _session_stream
from screen_locker.tests.test_session_stream import make_stream
if TYPE_CHECKING:
from screen_locker._session_stream import SessionStream
_LONG = 400.0 # longer than the 300 s minimum lifetime
@pytest.fixture
def stream(monkeypatch: pytest.MonkeyPatch) -> SessionStream:
"""A ``SessionStream`` that never reads or writes the real device id."""
return make_stream(monkeypatch)
class _Loop:
"""Scripted ``_run_one`` outcomes with a fake clock, jitter and stop event."""
def __init__(
self,
stream: SessionStream,
monkeypatch: pytest.MonkeyPatch,
script: list[tuple[bool, float, bool]],
factor: float = 1.0,
) -> None:
self.script = script
self.index = 0
self.now = 0.0
self.waits: list[float] = []
self.uniform_args: list[tuple[float, float]] = []
self.factor = factor
self.stream = stream
monkeypatch.setattr(stream, "_run_one", self._run_one)
monkeypatch.setattr(stream, "_stop", self)
monkeypatch.setattr(
_session_stream, "time", SimpleNamespace(monotonic=lambda: self.now)
)
monkeypatch.setattr(_session_stream, "_JITTER", self)
def _run_one(self) -> bool:
planned, duration, snapshot = self.script[self.index]
self.index += 1
self.now += duration
self.stream._got_snapshot = snapshot
return planned
def is_set(self) -> bool:
"""Stop once the script is exhausted."""
return self.index >= len(self.script)
def wait(self, pause: float) -> None:
"""Record the backoff instead of sleeping."""
self.waits.append(pause)
def uniform(self, low: float, high: float) -> float:
"""Record the jitter range; scale between the bounds by ``factor``."""
self.uniform_args.append((low, high))
return low + (high - low) * self.factor
class TestRunForever:
"""Reconnect backoff: growth, cap, reset and the tight-loop guard."""
@staticmethod
def _run(loop: _Loop) -> list[float]:
loop.stream.run_forever()
return loop.waits
def test_backoff_doubles_and_is_capped(
self, stream: SessionStream, monkeypatch: pytest.MonkeyPatch
) -> None:
"""1, 2, 4 ... capped at 60 s while connections keep failing fast."""
loop = _Loop(stream, monkeypatch, [(False, 0.0, False)] * 8)
assert self._run(loop) == [1, 2, 4, 8, 16, 32, 60, 60]
def test_pause_is_jittered_between_half_and_full(
self, stream: SessionStream, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The pause is the delay times a factor drawn from 0.5..1.0."""
loop = _Loop(stream, monkeypatch, [(False, 0.0, False)] * 2, factor=0.0)
assert self._run(loop) == [0.5, 1.0]
assert set(loop.uniform_args) == {(0.5, 1.0)}
def test_long_lived_connection_with_snapshot_resets_the_backoff(
self, stream: SessionStream, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A healthy connection (snapshot + 5 min) restarts the ladder at 1 s."""
script = [(False, 0.0, False)] * 3 + [(False, _LONG, True), (False, 0.0, False)]
assert self._run(_Loop(stream, monkeypatch, script)) == [1, 2, 4, 1, 2]
def test_long_connection_without_snapshot_does_not_reset(
self, stream: SessionStream, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Surviving 5 min without ever getting data proves nothing."""
script = [(False, 0.0, False)] * 3 + [(False, _LONG, False)]
assert self._run(_Loop(stream, monkeypatch, script)) == [1, 2, 4, 8]
def test_short_snapshot_connection_does_not_reset(
self, stream: SessionStream, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A snapshot that dies within 5 min is a flapping link: keep backing off."""
script = [(False, 0.0, False)] * 2 + [(False, 5.0, True)]
assert self._run(_Loop(stream, monkeypatch, script)) == [1, 2, 4]
def test_planned_end_after_a_long_life_reconnects_at_once(
self, stream: SessionStream, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Token renewal after a healthy hour is not a failure: no pause."""
script: list[tuple[bool, float, bool]] = [(True, 3600.0, True)] * 2
assert self._run(_Loop(stream, monkeypatch, script)) == []
def test_planned_end_after_a_short_life_still_backs_off(
self, stream: SessionStream, monkeypatch: pytest.MonkeyPatch
) -> None:
"""auth_revoked straight after connecting must not spin in a tight loop."""
script: list[tuple[bool, float, bool]] = [(True, 1.0, True)] * 3
assert self._run(_Loop(stream, monkeypatch, script)) == [1, 2, 4]
def test_each_pause_is_logged_as_a_warning(
self,
stream: SessionStream,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Reconnect pauses are visible in the journal."""
loop = _Loop(stream, monkeypatch, [(False, 0.0, False)])
with caplog.at_level(logging.WARNING):
self._run(loop)
(record,) = caplog.records
assert record.levelno == logging.WARNING
assert record.getMessage() == "Reconnecting the session stream in 1.0 s"
def test_stop_before_start_runs_nothing(
self, stream: SessionStream, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A set stop flag exits without connecting."""
loop = _Loop(stream, monkeypatch, [])
stream.run_forever()
assert loop.index == 0
class TestThread:
"""start() and stop()."""
def test_start_runs_the_loop_on_a_named_daemon_thread(
self, stream: SessionStream, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The loop runs off the caller's thread, as a daemon."""
ran: list[str] = []
monkeypatch.setattr(stream, "run_forever", lambda: ran.append("ran"))
thread = stream.start()
thread.join(timeout=5)
assert ran == ["ran"]
assert thread.daemon is True
assert thread.name == "firebase-session-stream"
def test_stop_sets_the_flag(self, stream: SessionStream) -> None:
"""stop() asks the loop to exit after the current connection."""
assert stream._stop.is_set() is False
stream.stop()
assert stream._stop.is_set() is True