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}