mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 21:27:13 +01:00
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018RHwVPyiiQMPh4M3JQwQku
172 lines
6.5 KiB
Python
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
|