diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cc51f4a..9395aafc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -515,6 +515,16 @@ policies are unchanged. `DisplayManager.cleanup()` (reached from SIGTERM through `run()`'s `finally`) unregisters it with the frame recorder. New `frame_timing.uninstall_gc_monitor()`. +- The web interface's state subscription (`StateSubscription`, + `src/ipc/client.py`) resubscribes about 1 s after a display restart, every + time. Its reconnect wait went back to the minimum only when the + subscription was stopped. A disconnect after a working connection kept + doubling the wait, so successive display restarts were followed by waits + of 1, 2, 4, 8, 16 and then 30 s for good. + During each wait the web answered from one-shot `state.get` connections + instead of its copy. The wait now resets once a connection has stored a + snapshot. A display that does not offer the stream is still retried + slowly. - A plugin reload after a store update (`plugin.reload`, #720) no longer freezes the panel during Vegas. On ledpi a football reload froze it for 3.0 s (`Render stall over: no frame for 3043ms`). The reload ran on the diff --git a/src/ipc/client.py b/src/ipc/client.py index 169fdb41..82749727 100644 --- a/src/ipc/client.py +++ b/src/ipc/client.py @@ -396,9 +396,9 @@ class StateSubscription: def _run(self) -> None: backoff = _RECONNECT_MIN_SECONDS while not self._stop.is_set(): + snapshots = self.snapshots try: self._follow() - backoff = _RECONNECT_MIN_SECONDS except ControlError as e: self.last_error = e.reason if e.reason in _SLOW_RETRY_REASONS: @@ -414,6 +414,11 @@ class StateSubscription: sock.close() except OSError: pass + if self.snapshots != snapshots: + # This connection got as far as the display's state: whatever + # ended it (a restart, most often), it was working, so the + # next try starts from the shortest wait again. + backoff = _RECONNECT_MIN_SECONDS if self._stop.wait(backoff): return backoff = min(backoff * 2, _RECONNECT_MAX_SECONDS) diff --git a/test/test_ipc_state_stream.py b/test/test_ipc_state_stream.py index d512d68c..5418a945 100644 --- a/test/test_ipc_state_stream.py +++ b/test/test_ipc_state_stream.py @@ -346,6 +346,52 @@ class TestSubscriptionStore: assert client.snapshot_loop_age(snap, now_mono=104.0) is None +class TestReconnectBackoff: + """StateSubscription._run's waits between connections, without a socket.""" + + def test_a_connection_that_got_a_snapshot_starts_the_backoff_over(self, hub, + monkeypatch): + """Three failed tries, then the display is back twice, restarting + each time, then gone again. Each restart is retried after the + shortest wait, not after whatever the waits had grown to.""" + sub = client.StateSubscription(paths=['/nowhere']) + script = ['refused', 'refused', 'refused', 'snapshot', 'snapshot', 'refused'] + waits = [] + + def follow(): + step = script.pop(0) + if step == 'snapshot': # subscribed, then the display restarted + sub._store(hub.snapshot(), full=True) + raise client.ControlError('closed', 'the display closed the connection') + raise client.ControlError(step) + + def wait(seconds): + waits.append(seconds) + return not script # True ends _run, as stop() would + + monkeypatch.setattr(sub, '_follow', follow) + monkeypatch.setattr(sub._stop, 'wait', wait) + sub._run() + first = client._RECONNECT_MIN_SECONDS + assert waits == [first, 2 * first, 4 * first, first, first, 2 * first] + + def test_a_display_without_the_stream_is_still_retried_slowly(self, monkeypatch): + sub = client.StateSubscription(paths=['/nowhere']) + waits = [] + + def follow(): + raise client.ControlError('unknown_command') + + def wait(seconds): + waits.append(seconds) + return len(waits) == 2 + + monkeypatch.setattr(sub, '_follow', follow) + monkeypatch.setattr(sub._stop, 'wait', wait) + sub._run() + assert waits == [client._RECONNECT_MAX_SECONDS] * 2 + + # --- a real socket ------------------------------------------------------------------ def _wait_until(predicate, timeout=5.0):