| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209 |
- """The in-app updater gives up on a step that hangs (TO-C7).
- Every git, pip and npm step was awaited with no deadline. A network stall in
- git fetch or npm install left the status at "downloading" or "installing" for
- good, and every retry answered "Update already in progress" until Bambuddy was
- restarted. Steps now have a deadline (git 300 s, pip and npm 900 s); the
- process is killed and the update ends with an error, except the optional
- frontend build, whose pre-built files ship with the release.
- """
- import asyncio
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- from backend.app.api.routes import updates as updates_module
- def _fake_exec(hang_on: str, procs: list):
- async def create(*args, **kwargs):
- proc = MagicMock()
- proc.returncode = 0
- proc.killed = False
- def kill():
- proc.killed = True
- proc.returncode = -9
- proc.kill = kill
- proc.terminate = kill
- proc.wait = AsyncMock(return_value=-9)
- if hang_on in args or (hang_on == "npm install" and args[:2] == ("/usr/bin/npm", "install")):
- async def never():
- await asyncio.Event().wait()
- proc.communicate = never
- else:
- proc.communicate = AsyncMock(return_value=(b"git@github.com:maziggy/bambuddy.git\n", b""))
- proc.args = args
- procs.append(proc)
- return proc
- return create
- async def _update(tmp_path, hang_on: str, *, npm: bool = False):
- app_dir = tmp_path / "app"
- (app_dir / "frontend").mkdir(parents=True)
- (app_dir / "requirements.txt").write_text("fastapi\n")
- procs: list = []
- def find(name):
- return "/usr/bin/npm" if name == "npm" and npm else ("/usr/bin/git" if name == "git" else None)
- with (
- patch.object(updates_module.settings, "app_dir", app_dir),
- patch.object(updates_module, "_find_executable", side_effect=find),
- patch.object(updates_module.asyncio, "create_subprocess_exec", side_effect=_fake_exec(hang_on, procs)),
- patch.object(updates_module, "GIT_STEP_TIMEOUT_SECONDS", 0.05),
- patch.object(updates_module, "INSTALL_STEP_TIMEOUT_SECONDS", 0.05),
- ):
- await asyncio.wait_for(updates_module._perform_update("v9.9.9"), 5)
- return updates_module._update_status, procs
- @pytest.fixture(autouse=True)
- def _idle_status():
- updates_module._update_status = {"status": "idle", "progress": 0, "message": "", "error": None}
- yield
- updates_module._update_status = {"status": "idle", "progress": 0, "message": "", "error": None}
- @pytest.mark.asyncio
- async def test_a_stalled_git_fetch_ends_the_update_with_an_error(tmp_path):
- status, procs = await _update(tmp_path, "fetch")
- assert status["status"] == "error"
- assert "timed out" in (status["error"] or "").lower()
- stuck = [p for p in procs if "fetch" in p.args]
- assert stuck and stuck[0].killed
- stuck[0].wait.assert_awaited()
- @pytest.mark.asyncio
- async def test_a_stalled_pip_install_ends_the_update_with_an_error(tmp_path):
- status, procs = await _update(tmp_path, "pip")
- assert status["status"] == "error"
- assert [p for p in procs if "pip" in p.args][0].killed
- # The code has already moved: it must not be started without its dependencies.
- assert "before restarting" in status["error"]
- @pytest.mark.asyncio
- async def test_a_stalled_frontend_build_falls_back_to_the_prebuilt_files(tmp_path):
- status, procs = await _update(tmp_path, "build", npm=True)
- assert status["status"] == "complete"
- assert [p for p in procs if "build" in p.args][0].killed
- @pytest.mark.asyncio
- async def test_after_a_timeout_the_update_can_be_tried_again(tmp_path):
- status, _ = await _update(tmp_path, "fetch")
- assert status["status"] not in ("downloading", "installing")
- def test_the_deadlines_are_generous():
- """Slow links and a Raspberry Pi: git 5 minutes, pip and npm 15."""
- assert updates_module.GIT_STEP_TIMEOUT_SECONDS == 300
- assert updates_module.INSTALL_STEP_TIMEOUT_SECONDS == 900
- @pytest.mark.asyncio
- async def test_a_stalled_npm_install_falls_back_to_the_prebuilt_files(tmp_path):
- status, procs = await _update(tmp_path, "npm install", npm=True)
- assert status["status"] == "complete"
- assert [p for p in procs if p.args[:2] == ("/usr/bin/npm", "install")][0].killed
- @pytest.mark.asyncio
- async def test_a_step_in_its_own_session_is_killed_with_its_children(tmp_path):
- """npm and pip start children of their own: killing only the parent would
- leave them holding the pipes, and the wait would never end."""
- import os
- import sys
- child_pid_file = tmp_path / "child.pid"
- script = (
- "import subprocess, sys, time;"
- "c = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)']);"
- f"open({str(child_pid_file)!r}, 'w').write(str(c.pid));"
- "time.sleep(60)"
- )
- process = await asyncio.create_subprocess_exec(
- sys.executable,
- "-c",
- script,
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE,
- start_new_session=True,
- )
- for _ in range(100):
- if child_pid_file.exists() and child_pid_file.read_text():
- break
- await asyncio.sleep(0.05)
- child = int(child_pid_file.read_text())
- with pytest.raises(updates_module._StepTimedOut):
- await asyncio.wait_for(updates_module._communicate(process, 0.1, "npm install"), 15)
- assert process.returncode is not None
- for _ in range(100):
- try:
- os.kill(child, 0)
- except ProcessLookupError:
- break
- await asyncio.sleep(0.05)
- else:
- os.kill(child, 9)
- pytest.fail("the step's child outlived it")
- @pytest.mark.asyncio
- async def test_a_stalled_git_reset_says_the_code_may_be_partly_in_place(tmp_path):
- status, _ = await _update(tmp_path, "reset")
- assert status["status"] == "error"
- assert "partly in place" in status["error"] and "before restarting" in status["error"]
- @pytest.mark.asyncio
- async def test_a_step_is_asked_to_stop_before_it_is_killed():
- """git removes its lock files on SIGTERM; SIGKILL would leave
- .git/index.lock behind and every retry would fail on it."""
- process = MagicMock(pid=None)
- process.communicate = asyncio.Event().wait
- process.wait = AsyncMock(return_value=-15)
- with pytest.raises(updates_module._StepTimedOut):
- await updates_module._communicate(process, 0.05, "git reset")
- process.terminate.assert_called_once()
- process.kill.assert_not_called()
- @pytest.mark.asyncio
- async def test_a_step_that_ignores_the_request_is_killed(monkeypatch):
- process = MagicMock(pid=None)
- process.communicate = asyncio.Event().wait
- waits = iter([TimeoutError(), -9])
- async def wait():
- result = next(waits)
- if isinstance(result, Exception):
- raise result
- return result
- process.wait = wait
- monkeypatch.setattr(updates_module, "TERMINATE_GRACE_SECONDS", 0.05)
- with pytest.raises(updates_module._StepTimedOut):
- await updates_module._communicate(process, 0.05, "npm install")
- process.terminate.assert_called_once()
- process.kill.assert_called_once()
|