Go2rtc server start is waiting until we got the api listen stdout line (#129391)

This commit is contained in:
Robert Resch 2024-10-29 11:28:40 +01:00 committed by GitHub
parent 6c664e7ba9
commit 13416825b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 121 additions and 55 deletions

View file

@ -5,9 +5,12 @@ import logging
from tempfile import NamedTemporaryFile
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
_LOGGER = logging.getLogger(__name__)
_TERMINATE_TIMEOUT = 5
_SETUP_TIMEOUT = 30
_SUCCESSFUL_BOOT_MESSAGE = "INF [api] listen addr=127.0.0.1:1984"
# Default configuration for HA
# - Api is listening only on localhost
@ -34,14 +37,6 @@ def _create_temp_file() -> str:
return file.name
async def _log_output(process: asyncio.subprocess.Process) -> None:
"""Log the output of the process."""
assert process.stdout is not None
async for line in process.stdout:
_LOGGER.debug(line[:-1].decode().strip())
class Server:
"""Go2rtc server."""
@ -50,12 +45,15 @@ class Server:
self._hass = hass
self._binary = binary
self._process: asyncio.subprocess.Process | None = None
self._startup_complete = asyncio.Event()
async def start(self) -> None:
"""Start the server."""
_LOGGER.debug("Starting go2rtc server")
config_file = await self._hass.async_add_executor_job(_create_temp_file)
self._startup_complete.clear()
self._process = await asyncio.create_subprocess_exec(
self._binary,
"-c",
@ -66,9 +64,30 @@ class Server:
)
self._hass.async_create_background_task(
_log_output(self._process), "Go2rtc log output"
self._log_output(self._process), "Go2rtc log output"
)
try:
async with asyncio.timeout(_SETUP_TIMEOUT):
await self._startup_complete.wait()
except TimeoutError as err:
msg = "Go2rtc server didn't start correctly"
_LOGGER.exception(msg)
await self.stop()
raise HomeAssistantError("Go2rtc server didn't start correctly") from err
async def _log_output(self, process: asyncio.subprocess.Process) -> None:
"""Log the output of the process."""
assert process.stdout is not None
async for line in process.stdout:
msg = line[:-1].decode().strip()
_LOGGER.debug(msg)
if not self._startup_complete.is_set() and msg.endswith(
_SUCCESSFUL_BOOT_MESSAGE
):
self._startup_complete.set()
async def stop(self) -> None:
"""Stop the server."""
if self._process:

View file

@ -35,17 +35,39 @@ def ws_client() -> Generator[Mock]:
@pytest.fixture
def server_start() -> Generator[AsyncMock]:
"""Mock start of a go2rtc server."""
with (
patch(f"{GO2RTC_PATH}.server.asyncio.create_subprocess_exec") as mock_subproc,
patch(
f"{GO2RTC_PATH}.server.Server.start", wraps=Server.start, autospec=True
) as mock_server_start,
):
def server_stdout() -> list[str]:
"""Server stdout lines."""
return [
"09:00:03.466 INF go2rtc platform=linux/amd64 revision=780f378 version=1.9.5",
"09:00:03.466 INF config path=/tmp/go2rtc.yaml",
"09:00:03.467 INF [rtsp] listen addr=:8554",
"09:00:03.467 INF [api] listen addr=127.0.0.1:1984",
"09:00:03.467 INF [webrtc] listen addr=:8555/tcp",
]
@pytest.fixture
def mock_create_subprocess(server_stdout: list[str]) -> Generator[AsyncMock]:
"""Mock create_subprocess_exec."""
with patch(f"{GO2RTC_PATH}.server.asyncio.create_subprocess_exec") as mock_subproc:
subproc = AsyncMock()
subproc.terminate = Mock()
subproc.kill = Mock()
subproc.returncode = None
# Simulate process output
subproc.stdout.__aiter__.return_value = iter(
[f"{entry}\n".encode() for entry in server_stdout]
)
mock_subproc.return_value = subproc
yield mock_subproc
@pytest.fixture
def server_start(mock_create_subprocess: AsyncMock) -> Generator[AsyncMock]:
"""Mock start of a go2rtc server."""
with patch(
f"{GO2RTC_PATH}.server.Server.start", wraps=Server.start, autospec=True
) as mock_server_start:
yield mock_server_start
@ -61,7 +83,7 @@ def server_stop() -> Generator[AsyncMock]:
@pytest.fixture
def server(server_start, server_stop) -> Generator[AsyncMock]:
def server(server_start: AsyncMock, server_stop: AsyncMock) -> Generator[AsyncMock]:
"""Mock a go2rtc server."""
with patch(f"{GO2RTC_PATH}.Server", wraps=Server) as mock_server:
yield mock_server

View file

@ -4,12 +4,13 @@ import asyncio
from collections.abc import Generator
import logging
import subprocess
from unittest.mock import MagicMock, Mock, patch
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from homeassistant.components.go2rtc.server import Server
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
TEST_BINARY = "/bin/go2rtc"
@ -31,37 +32,18 @@ def mock_tempfile() -> Generator[Mock]:
yield file
@pytest.fixture
def mock_process() -> Generator[MagicMock]:
"""Fixture to mock subprocess.Popen."""
with patch(
"homeassistant.components.go2rtc.server.asyncio.create_subprocess_exec"
) as mock_popen:
mock_popen.return_value.terminate = MagicMock()
mock_popen.return_value.kill = MagicMock()
mock_popen.return_value.returncode = None
yield mock_popen
async def test_server_run_success(
mock_process: MagicMock,
mock_create_subprocess: AsyncMock,
server_stdout: list[str],
server: Server,
caplog: pytest.LogCaptureFixture,
mock_tempfile: Mock,
) -> None:
"""Test that the server runs successfully."""
# Simulate process output
mock_process.return_value.stdout.__aiter__.return_value = iter(
[
b"log line 1\n",
b"log line 2\n",
]
)
await server.start()
# Check that Popen was called with the right arguments
mock_process.assert_called_once_with(
mock_create_subprocess.assert_called_once_with(
TEST_BINARY,
"-c",
"test.yaml",
@ -83,7 +65,7 @@ webrtc:
""")
# Check that server read the log lines
for entry in ("log line 1", "log line 2"):
for entry in server_stdout:
assert (
"homeassistant.components.go2rtc.server",
logging.DEBUG,
@ -91,31 +73,74 @@ webrtc:
) in caplog.record_tuples
await server.stop()
mock_process.return_value.terminate.assert_called_once()
mock_create_subprocess.return_value.terminate.assert_called_once()
@pytest.mark.usefixtures("mock_tempfile")
async def test_server_run_process_timeout(
mock_process: MagicMock, server: Server
async def test_server_timeout_on_stop(
mock_create_subprocess: MagicMock, server: Server
) -> None:
"""Test server run where the process takes too long to terminate."""
mock_process.return_value.stdout.__aiter__.return_value = iter(
[
b"log line 1\n",
]
)
# Start server thread
await server.start()
async def sleep() -> None:
await asyncio.sleep(1)
# Simulate timeout
mock_process.return_value.wait.side_effect = sleep
mock_create_subprocess.return_value.wait.side_effect = sleep
with patch("homeassistant.components.go2rtc.server._TERMINATE_TIMEOUT", new=0.1):
# Start server thread
await server.start()
await server.stop()
# Ensure terminate and kill were called due to timeout
mock_process.return_value.terminate.assert_called_once()
mock_process.return_value.kill.assert_called_once()
mock_create_subprocess.return_value.terminate.assert_called_once()
mock_create_subprocess.return_value.kill.assert_called_once()
@pytest.mark.parametrize(
"server_stdout",
[
[
"09:00:03.466 INF go2rtc platform=linux/amd64 revision=780f378 version=1.9.5",
"09:00:03.466 INF config path=/tmp/go2rtc.yaml",
]
],
)
@pytest.mark.usefixtures("mock_tempfile")
async def test_server_failed_to_start(
mock_create_subprocess: MagicMock,
server_stdout: list[str],
server: Server,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test server, where an exception is raised if the expected log entry was not received until the timeout."""
with (
patch("homeassistant.components.go2rtc.server._SETUP_TIMEOUT", new=0.1),
pytest.raises(HomeAssistantError, match="Go2rtc server didn't start correctly"),
):
await server.start()
# Verify go2rtc binary stdout was logged
for entry in server_stdout:
assert (
"homeassistant.components.go2rtc.server",
logging.DEBUG,
entry,
) in caplog.record_tuples
assert (
"homeassistant.components.go2rtc.server",
logging.ERROR,
"Go2rtc server didn't start correctly",
) in caplog.record_tuples
# Check that Popen was called with the right arguments
mock_create_subprocess.assert_called_once_with(
TEST_BINARY,
"-c",
"test.yaml",
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
close_fds=False,
)