test_updater_step_timeouts.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. """The in-app updater gives up on a step that hangs (TO-C7).
  2. Every git, pip and npm step was awaited with no deadline. A network stall in
  3. git fetch or npm install left the status at "downloading" or "installing" for
  4. good, and every retry answered "Update already in progress" until Bambuddy was
  5. restarted. Steps now have a deadline (git 300 s, pip and npm 900 s); the
  6. process is killed and the update ends with an error, except the optional
  7. frontend build, whose pre-built files ship with the release.
  8. """
  9. import asyncio
  10. from unittest.mock import AsyncMock, MagicMock, patch
  11. import pytest
  12. from backend.app.api.routes import updates as updates_module
  13. def _fake_exec(hang_on: str, procs: list):
  14. async def create(*args, **kwargs):
  15. proc = MagicMock()
  16. proc.returncode = 0
  17. proc.killed = False
  18. def kill():
  19. proc.killed = True
  20. proc.returncode = -9
  21. proc.kill = kill
  22. proc.terminate = kill
  23. proc.wait = AsyncMock(return_value=-9)
  24. if hang_on in args or (hang_on == "npm install" and args[:2] == ("/usr/bin/npm", "install")):
  25. async def never():
  26. await asyncio.Event().wait()
  27. proc.communicate = never
  28. else:
  29. proc.communicate = AsyncMock(return_value=(b"git@github.com:maziggy/bambuddy.git\n", b""))
  30. proc.args = args
  31. procs.append(proc)
  32. return proc
  33. return create
  34. async def _update(tmp_path, hang_on: str, *, npm: bool = False):
  35. app_dir = tmp_path / "app"
  36. (app_dir / "frontend").mkdir(parents=True)
  37. (app_dir / "requirements.txt").write_text("fastapi\n")
  38. procs: list = []
  39. def find(name):
  40. return "/usr/bin/npm" if name == "npm" and npm else ("/usr/bin/git" if name == "git" else None)
  41. with (
  42. patch.object(updates_module.settings, "app_dir", app_dir),
  43. patch.object(updates_module, "_find_executable", side_effect=find),
  44. patch.object(updates_module.asyncio, "create_subprocess_exec", side_effect=_fake_exec(hang_on, procs)),
  45. patch.object(updates_module, "GIT_STEP_TIMEOUT_SECONDS", 0.05),
  46. patch.object(updates_module, "INSTALL_STEP_TIMEOUT_SECONDS", 0.05),
  47. ):
  48. await asyncio.wait_for(updates_module._perform_update("v9.9.9"), 5)
  49. return updates_module._update_status, procs
  50. @pytest.fixture(autouse=True)
  51. def _idle_status():
  52. updates_module._update_status = {"status": "idle", "progress": 0, "message": "", "error": None}
  53. yield
  54. updates_module._update_status = {"status": "idle", "progress": 0, "message": "", "error": None}
  55. @pytest.mark.asyncio
  56. async def test_a_stalled_git_fetch_ends_the_update_with_an_error(tmp_path):
  57. status, procs = await _update(tmp_path, "fetch")
  58. assert status["status"] == "error"
  59. assert "timed out" in (status["error"] or "").lower()
  60. stuck = [p for p in procs if "fetch" in p.args]
  61. assert stuck and stuck[0].killed
  62. stuck[0].wait.assert_awaited()
  63. @pytest.mark.asyncio
  64. async def test_a_stalled_pip_install_ends_the_update_with_an_error(tmp_path):
  65. status, procs = await _update(tmp_path, "pip")
  66. assert status["status"] == "error"
  67. assert [p for p in procs if "pip" in p.args][0].killed
  68. # The code has already moved: it must not be started without its dependencies.
  69. assert "before restarting" in status["error"]
  70. @pytest.mark.asyncio
  71. async def test_a_stalled_frontend_build_falls_back_to_the_prebuilt_files(tmp_path):
  72. status, procs = await _update(tmp_path, "build", npm=True)
  73. assert status["status"] == "complete"
  74. assert [p for p in procs if "build" in p.args][0].killed
  75. @pytest.mark.asyncio
  76. async def test_after_a_timeout_the_update_can_be_tried_again(tmp_path):
  77. status, _ = await _update(tmp_path, "fetch")
  78. assert status["status"] not in ("downloading", "installing")
  79. def test_the_deadlines_are_generous():
  80. """Slow links and a Raspberry Pi: git 5 minutes, pip and npm 15."""
  81. assert updates_module.GIT_STEP_TIMEOUT_SECONDS == 300
  82. assert updates_module.INSTALL_STEP_TIMEOUT_SECONDS == 900
  83. @pytest.mark.asyncio
  84. async def test_a_stalled_npm_install_falls_back_to_the_prebuilt_files(tmp_path):
  85. status, procs = await _update(tmp_path, "npm install", npm=True)
  86. assert status["status"] == "complete"
  87. assert [p for p in procs if p.args[:2] == ("/usr/bin/npm", "install")][0].killed
  88. @pytest.mark.asyncio
  89. async def test_a_step_in_its_own_session_is_killed_with_its_children(tmp_path):
  90. """npm and pip start children of their own: killing only the parent would
  91. leave them holding the pipes, and the wait would never end."""
  92. import os
  93. import sys
  94. child_pid_file = tmp_path / "child.pid"
  95. script = (
  96. "import subprocess, sys, time;"
  97. "c = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)']);"
  98. f"open({str(child_pid_file)!r}, 'w').write(str(c.pid));"
  99. "time.sleep(60)"
  100. )
  101. process = await asyncio.create_subprocess_exec(
  102. sys.executable,
  103. "-c",
  104. script,
  105. stdout=asyncio.subprocess.PIPE,
  106. stderr=asyncio.subprocess.PIPE,
  107. start_new_session=True,
  108. )
  109. for _ in range(100):
  110. if child_pid_file.exists() and child_pid_file.read_text():
  111. break
  112. await asyncio.sleep(0.05)
  113. child = int(child_pid_file.read_text())
  114. with pytest.raises(updates_module._StepTimedOut):
  115. await asyncio.wait_for(updates_module._communicate(process, 0.1, "npm install"), 15)
  116. assert process.returncode is not None
  117. for _ in range(100):
  118. try:
  119. os.kill(child, 0)
  120. except ProcessLookupError:
  121. break
  122. await asyncio.sleep(0.05)
  123. else:
  124. os.kill(child, 9)
  125. pytest.fail("the step's child outlived it")
  126. @pytest.mark.asyncio
  127. async def test_a_stalled_git_reset_says_the_code_may_be_partly_in_place(tmp_path):
  128. status, _ = await _update(tmp_path, "reset")
  129. assert status["status"] == "error"
  130. assert "partly in place" in status["error"] and "before restarting" in status["error"]
  131. @pytest.mark.asyncio
  132. async def test_a_step_is_asked_to_stop_before_it_is_killed():
  133. """git removes its lock files on SIGTERM; SIGKILL would leave
  134. .git/index.lock behind and every retry would fail on it."""
  135. process = MagicMock(pid=None)
  136. process.communicate = asyncio.Event().wait
  137. process.wait = AsyncMock(return_value=-15)
  138. with pytest.raises(updates_module._StepTimedOut):
  139. await updates_module._communicate(process, 0.05, "git reset")
  140. process.terminate.assert_called_once()
  141. process.kill.assert_not_called()
  142. @pytest.mark.asyncio
  143. async def test_a_step_that_ignores_the_request_is_killed(monkeypatch):
  144. process = MagicMock(pid=None)
  145. process.communicate = asyncio.Event().wait
  146. waits = iter([TimeoutError(), -9])
  147. async def wait():
  148. result = next(waits)
  149. if isinstance(result, Exception):
  150. raise result
  151. return result
  152. process.wait = wait
  153. monkeypatch.setattr(updates_module, "TERMINATE_GRACE_SECONDS", 0.05)
  154. with pytest.raises(updates_module._StepTimedOut):
  155. await updates_module._communicate(process, 0.05, "npm install")
  156. process.terminate.assert_called_once()
  157. process.kill.assert_called_once()