Serialize runtime package preparation

Run Office and Desktop apt operations through a shared in-process retry guard so startup/self-update hooks wait out transient apt locks instead of failing early. Include LibreOffice runtime packages in Desktop preparation because Desktop status and Writer/Calc/Impress launch paths require soffice, and cover both behaviors with regression tests.

Alessandro committed May 7, 2026 at 03:14 UTC ef011600eff8bfab1b07ca7033260925f5473413
4 files changed +136 -56
helpers/system_packages.py new
+42
@@ -0,0 +1,42 @@
1 +from __future__ import annotations
2 +
3 +import subprocess
4 +import threading
5 +import time
6 +from typing import Callable
7 +
8 +
9 +APT_LOCK_TIMEOUT_SECONDS = 240
10 +APT_LOCK_RETRY_SECONDS = 5
11 +
12 +_apt_lock = threading.RLock()
13 +
14 +
15 +def run_apt_with_retries(
16 + runner: Callable[[], subprocess.CompletedProcess[str]],
17 + *,
18 + lock_timeout_seconds: int = APT_LOCK_TIMEOUT_SECONDS,
19 + retry_seconds: int = APT_LOCK_RETRY_SECONDS,
20 +) -> subprocess.CompletedProcess[str]:
21 + """Run an apt/dpkg command, serializing in-process callers and waiting out apt locks."""
22 +
23 + with _apt_lock:
24 + deadline = time.monotonic() + max(0, lock_timeout_seconds)
25 + while True:
26 + result = runner()
27 + if result.returncode == 0 or not is_apt_lock_error(result):
28 + return result
29 + remaining = deadline - time.monotonic()
30 + if remaining <= 0:
31 + return result
32 + time.sleep(min(max(1, retry_seconds), remaining))
33 +
34 +
35 +def is_apt_lock_error(result: subprocess.CompletedProcess[str]) -> bool:
36 + output = f"{result.stderr or ''}\n{result.stdout or ''}".lower()
37 + return (
38 + "could not get lock" in output
39 + or "unable to lock directory" in output
40 + or "unable to acquire the dpkg frontend lock" in output
41 + or "is another process using it" in output
42 + )
plugins/_desktop/hooks.py
+28 -30
@@ -7,11 +7,21 @@ import urllib.request
7 from pathlib import Path
8 from typing import Any
9
10 -
10 +from helpers import system_packages
11 +
12 +LIBREOFFICE_RUNTIME_PACKAGES = (
13 + "libreoffice-core",
14 + "libreoffice-writer",
15 + "libreoffice-calc",
16 + "libreoffice-impress",
17 + "libreoffice-gtk3",
18 + "python3-uno",
19 +)
20 XPRA_SOURCE_FILE = Path("/etc/apt/sources.list.d/xpra.sources")
21 XPRA_KEYRING_FILE = Path("/usr/share/keyrings/xpra.asc")
22 XPRA_KEY_URL = "https://xpra.org/xpra.asc"
23 RUNTIME_PACKAGES = (
24 + *LIBREOFFICE_RUNTIME_PACKAGES,
25 "xpra-server",
26 "xpra-client",
27 "xpra-client-gtk3",
@@ -102,14 +112,7 @@ def _purge_packages(
112 installed = installed_packages if installed_packages is not None else []
113 if not installed:
114 return
105 - result = subprocess.run(
106 - ["apt-get", "purge", "-y", *installed],
107 - check=False,
108 - text=True,
109 - capture_output=True,
110 - timeout=180,
111 - env={**os.environ, "DEBIAN_FRONTEND": "noninteractive"},
112 - )
115 + result = _run_apt_command(["apt-get", "purge", "-y", *installed], timeout=180)
116 if result.returncode == 0:
117 removed.extend(installed)
118 return
@@ -161,14 +164,7 @@ def _install_runtime_packages(
164 *,
165 optional: bool = False,
166 ) -> bool:
164 - result = subprocess.run(
165 - ["apt-get", "install", "-y", "--no-install-recommends", *packages],
166 - check=False,
167 - text=True,
168 - capture_output=True,
169 - timeout=900,
170 - env={**os.environ, "DEBIAN_FRONTEND": "noninteractive"},
171 - )
167 + result = _run_apt_command(["apt-get", "install", "-y", "--no-install-recommends", *packages], timeout=900)
168 if result.returncode == 0:
169 installed.extend(packages)
170 return True
@@ -185,14 +181,7 @@ def _is_xpra_codec_dependency_gap(output: str) -> bool:
181
182
183 def _apt_update(errors: list[str]) -> bool:
188 - result = subprocess.run(
189 - ["apt-get", "update"],
190 - check=False,
191 - text=True,
192 - capture_output=True,
193 - timeout=300,
194 - env={**os.environ, "DEBIAN_FRONTEND": "noninteractive"},
195 - )
184 + result = _run_apt_command(["apt-get", "update"], timeout=300)
185 if result.returncode == 0:
186 return True
187 errors.append((result.stderr or result.stdout or "apt-get update failed").strip())
@@ -222,13 +211,9 @@ def _package_candidates_available(packages: list[str]) -> bool:
211
212 def _ensure_xpra_repository(installed: list[str], errors: list[str]) -> None:
213 if not _package_installed("ca-certificates"):
225 - result = subprocess.run(
214 + result = _run_apt_command(
215 ["apt-get", "install", "-y", "--no-install-recommends", "ca-certificates"],
227 - check=False,
228 - text=True,
229 - capture_output=True,
216 timeout=180,
231 - env={**os.environ, "DEBIAN_FRONTEND": "noninteractive"},
217 )
218 if result.returncode != 0:
219 errors.append((result.stderr or result.stdout or "apt-get install ca-certificates failed").strip())
@@ -254,6 +239,19 @@ def _download(url: str) -> bytes:
239 return response.read()
240
241
242 +def _run_apt_command(command: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
243 + return system_packages.run_apt_with_retries(
244 + lambda: subprocess.run(
245 + command,
246 + check=False,
247 + text=True,
248 + capture_output=True,
249 + timeout=timeout,
250 + env={**os.environ, "DEBIAN_FRONTEND": "noninteractive"},
251 + )
252 + )
253 +
254 +
255 def _xpra_repository_source() -> str:
256 os_release = _read_os_release()
257 os_id = os_release.get("ID", "")
plugins/_office/hooks.py
+17 -25
@@ -6,7 +6,7 @@ import subprocess
6 from pathlib import Path
7 from typing import Any
8
9 -from helpers import files
9 +from helpers import files, system_packages
10
11
12 PROJECT_ROOT = Path(__file__).resolve().parents[2]
@@ -305,14 +305,7 @@ def _purge_packages(
305 installed = installed_packages if installed_packages is not None else _installed_packages(RETIRED_WEB_PACKAGES)
306 if not installed:
307 return
308 - result = subprocess.run(
309 - ["apt-get", "purge", "-y", *installed],
310 - check=False,
311 - text=True,
312 - capture_output=True,
313 - timeout=180,
314 - env={**os.environ, "DEBIAN_FRONTEND": "noninteractive"},
315 - )
308 + result = _run_apt_command(["apt-get", "purge", "-y", *installed], timeout=180)
309 if result.returncode == 0:
310 removed.extend(installed)
311 return
@@ -348,14 +341,7 @@ def _install_runtime_packages(
341 installed: list[str],
342 errors: list[str],
343 ) -> bool:
351 - result = subprocess.run(
352 - ["apt-get", "install", "-y", "--no-install-recommends", *packages],
353 - check=False,
354 - text=True,
355 - capture_output=True,
356 - timeout=900,
357 - env={**os.environ, "DEBIAN_FRONTEND": "noninteractive"},
358 - )
344 + result = _run_apt_command(["apt-get", "install", "-y", "--no-install-recommends", *packages], timeout=900)
345 if result.returncode == 0:
346 installed.extend(packages)
347 return True
@@ -365,15 +351,21 @@ def _install_runtime_packages(
351
352
353 def _apt_update(errors: list[str]) -> bool:
368 - result = subprocess.run(
369 - ["apt-get", "update"],
370 - check=False,
371 - text=True,
372 - capture_output=True,
373 - timeout=300,
374 - env={**os.environ, "DEBIAN_FRONTEND": "noninteractive"},
375 - )
354 + result = _run_apt_command(["apt-get", "update"], timeout=300)
355 if result.returncode == 0:
356 return True
357 errors.append((result.stderr or result.stdout or "apt-get update failed").strip())
358 return False
359 +
360 +
361 +def _run_apt_command(command: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
362 + return system_packages.run_apt_with_retries(
363 + lambda: subprocess.run(
364 + command,
365 + check=False,
366 + text=True,
367 + capture_output=True,
368 + timeout=timeout,
369 + env={**os.environ, "DEBIAN_FRONTEND": "noninteractive"},
370 + )
371 + )
tests/test_office_document_store.py
+49 -1
@@ -13,11 +13,11 @@ from pathlib import Path
13
14 import pytest
15
16 -
16 PROJECT_ROOT = Path(__file__).resolve().parents[1]
17 if str(PROJECT_ROOT) not in sys.path:
18 sys.path.insert(0, str(PROJECT_ROOT))
19
20 +from helpers import system_packages
21 from plugins._office import hooks
22 from plugins._desktop import hooks as desktop_hooks
23 from plugins._desktop.helpers import desktop_session
@@ -1334,6 +1334,54 @@ def test_cleanup_hook_removes_retired_supervisor_program_after_marker(tmp_path,
1334 ]
1335
1336
1337 +def test_office_runtime_dependency_install_waits_out_apt_locks(monkeypatch):
1338 + calls = []
1339 + installed_state = {"libreoffice-core": False}
1340 + update_attempts = {"count": 0}
1341 +
1342 + monkeypatch.setattr(hooks.os, "geteuid", lambda: 0)
1343 + monkeypatch.setattr(
1344 + hooks.shutil,
1345 + "which",
1346 + lambda name: f"/usr/bin/{name}" if name in {"apt-get", "dpkg-query"} else "",
1347 + )
1348 + monkeypatch.setattr(hooks, "RUNTIME_PACKAGES", ("libreoffice-core",))
1349 + monkeypatch.setattr(hooks, "_package_installed", lambda package: installed_state.get(package, False))
1350 + monkeypatch.setattr(system_packages.time, "sleep", lambda _seconds: None)
1351 +
1352 + def fake_run(command, **kwargs):
1353 + calls.append(command)
1354 + if command == ["apt-get", "update"]:
1355 + update_attempts["count"] += 1
1356 + if update_attempts["count"] == 1:
1357 + return types.SimpleNamespace(
1358 + returncode=100,
1359 + stdout="",
1360 + stderr="E: Could not get lock /var/lib/apt/lists/lock. It is held by process 1829 (apt-get)",
1361 + )
1362 + if command[:2] == ["apt-get", "install"]:
1363 + installed_state["libreoffice-core"] = True
1364 + return types.SimpleNamespace(returncode=0, stdout="", stderr="")
1365 +
1366 + monkeypatch.setattr(hooks.subprocess, "run", fake_run)
1367 + installed = []
1368 + errors = []
1369 +
1370 + hooks._ensure_runtime_dependencies(installed, errors)
1371 +
1372 + assert errors == []
1373 + assert installed == ["libreoffice-core"]
1374 + assert calls[:2] == [["apt-get", "update"], ["apt-get", "update"]]
1375 + assert calls[2][:4] == ["apt-get", "install", "-y", "--no-install-recommends"]
1376 +
1377 +
1378 +def test_desktop_runtime_packages_include_libreoffice_for_desktop_status():
1379 + assert set(desktop_hooks.LIBREOFFICE_RUNTIME_PACKAGES).issubset(desktop_hooks.RUNTIME_PACKAGES)
1380 + assert "libreoffice-writer" in desktop_hooks.RUNTIME_PACKAGES
1381 + assert "libreoffice-calc" in desktop_hooks.RUNTIME_PACKAGES
1382 + assert "libreoffice-impress" in desktop_hooks.RUNTIME_PACKAGES
1383 +
1384 +
1385 def test_cleanup_hook_installs_missing_desktop_session_dependencies(monkeypatch):
1386 calls = []
1387 installed_state = {"xpra": False}