From abe0aaeec2af41e53dfaf7193ec6186323c52888 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Wed, 21 Oct 2026 14:21:00 +0100 Subject: [PATCH 1/3] fix(updater): stage fixes for Release-2.0 field updates --- .../devices/storage/usb_controller.py | 7 +- BlocksScreen/screensaver.py | 2 + .../configuration_manager/configurator.py | 40 +++++--- scripts/bs-bootstrap.sh | 4 + scripts/install-updater.sh | 3 + scripts/post-merge | 3 + tests/storage/test_usb_controller_unit.py | 91 +++++++++++++++++++ tests/util/test_configurator_unit.py | 35 +++++++ updater/components.yaml | 2 + 9 files changed, 175 insertions(+), 12 deletions(-) create mode 100644 tests/storage/test_usb_controller_unit.py diff --git a/BlocksScreen/devices/storage/usb_controller.py b/BlocksScreen/devices/storage/usb_controller.py index ac27b395..d8ac620e 100644 --- a/BlocksScreen/devices/storage/usb_controller.py +++ b/BlocksScreen/devices/storage/usb_controller.py @@ -53,6 +53,7 @@ def __init__(self, parent: QtCore.QObject, gcodes_dir: str | None) -> None: ) self.banner = BannerPopup() self._restart_type: ResType = "always" + self._closing: bool = False self.udisks.start(self.udisks.Priority.InheritPriority) self.udisks.hardware_detected.connect(self.handle_new_hardware) self.udisks.hardware_detected.connect(self.usb_hardware_detected) @@ -75,6 +76,8 @@ def __init__(self, parent: QtCore.QObject, gcodes_dir: str | None) -> None: def restart(self) -> None: """Restart usb monitoring tool""" + if self._closing: + return if not self.udisks.active: self.udisks.start(self.udisks.Priority.InheritPriority) return @@ -83,11 +86,13 @@ def restart(self) -> None: def close(self) -> None: """Close usb monitoring tool""" + # A queued `finished` must not restart the thread deleteLater destroys (qFatal). + self._closing = True self.udisks.close() self.deleteLater() def _handle_full_restart(self) -> None: - if self.need_restart: + if self.need_restart and not self._closing: self.udisks.start(self.udisks.Priority.InheritPriority) self.need_restart = False diff --git a/BlocksScreen/screensaver.py b/BlocksScreen/screensaver.py index 6a5e4e80..1d40badf 100644 --- a/BlocksScreen/screensaver.py +++ b/BlocksScreen/screensaver.py @@ -1,3 +1,5 @@ +"""Inactivity screensaver: DPMS display blanking and wake-touch suppression.""" + import helper_methods as helper_methods from PyQt6 import QtCore, QtWidgets diff --git a/BlocksScreen/tools/configuration_manager/configurator.py b/BlocksScreen/tools/configuration_manager/configurator.py index cd619be3..caf56e33 100644 --- a/BlocksScreen/tools/configuration_manager/configurator.py +++ b/BlocksScreen/tools/configuration_manager/configurator.py @@ -21,6 +21,8 @@ VARIABLES_FILE: pathlib.Path = KLIPPER_CONFIG_DIR / "variables.cfg" PRINTER_CONFIG: pathlib.Path = KLIPPER_CONFIG_DIR / "printer.cfg" SV_CONFIG_MARKER = "#*# <---------------------- SAVE_CONFIG ---------------------->" +# Copied only when missing; bs_migrate_moonraker_conf and Mainsail own it after that. +SEED_ONLY_FILES: frozenset[str] = frozenset({"moonraker.conf"}) _logger = logging.getLogger(__name__) @@ -542,12 +544,13 @@ def merge_cfg( target_cfg.read_string(_tfl) appendix = [] + inserts: dict[str, list[tuple[str, str]]] = {} for section in src_cfg.sections(): if section.startswith("mcu") or section.startswith("beacon"): continue if not target_cfg.has_section(section): appendix.append( - (section, True, list(src_cfg.items(section, raw=True))) + (section, list(src_cfg.items(section, raw=True))) ) else: sec_missing = [ @@ -556,33 +559,46 @@ def merge_cfg( if not target_cfg.has_option(section, o) ] if sec_missing: - appendix.append((section, False, sec_missing)) + inserts[section] = sec_missing + + base = _tfl + if inserts: + # A repeated header breaks Klipper save_variables' parser. + tlines = _tfl.splitlines(keepends=True) + for i in range(len(tlines) - 1, -1, -1): + m = configparser.ConfigParser.SECTCRE.match( + tlines[i].strip() + ) + if m and m.group("header") in inserts: + opts = inserts.pop(m.group("header")) + tlines[i] = tlines[i].rstrip("\n") + "\n" + tlines[i + 1 : i + 1] = [f"{o}= {v}\n" for o, v in opts] + base = "".join(tlines) for opt, val in src_cfg.defaults().items(): if not target_cfg.defaults().get(opt): items = ( - appendix[-1][2] + appendix[-1][1] if appendix and appendix[-1][0] == "DEFAULT" else None ) if items is not None: items.append((opt, val)) else: - appendix.append(("DEFAULT", True, [(opt, val)])) + appendix.append(("DEFAULT", [(opt, val)])) if appendix: lines = [] - for section, is_new, opts in appendix: - if is_new: - lines.append(f"[{section}]") + for section, opts in appendix: + lines.append(f"[{section}]") for opt, val in opts: lines.append(f"{opt}= {val}") text = "\n".join(lines) - if _tfl and not _tfl.endswith("\n"): - _tfl += "\n" - merged = _tfl + text + "\n" + if base and not base.endswith("\n"): + base += "\n" + merged = base + text + "\n" else: - merged = _tfl + merged = base merged = self._fix_beacon_serial(merged) if _tfl != merged: @@ -617,6 +633,8 @@ def _cpy_cfg_files(self) -> None: _logger.info("File created") continue + if f in SEED_ONLY_FILES: + continue target = min(target_files, key=lambda p: len(p.parents)) if target.exists(): chk_src = get_file_checksum(src_file) diff --git a/scripts/bs-bootstrap.sh b/scripts/bs-bootstrap.sh index bb4df19a..1f3f780d 100755 --- a/scripts/bs-bootstrap.sh +++ b/scripts/bs-bootstrap.sh @@ -46,6 +46,8 @@ _deps_broken() { REINSTALL="--force-reinstall --no-cache-dir" rm -f "$SENTINEL" "$ATTEMPT" 2>/dev/null || true } +# Every venv writer (also install-updater.sh, post-merge) flocks the venv dir: a probe mid-replace forces a reinstall. +{ exec 201<"$BSENV" && flock -w 900 201; } || echo "[bs-bootstrap] venv lock not acquired (busy 900s or no venv) - continuing unlocked" # Repair the daemon's own dependency up front so an update can still run if the full set below fails. if ! _probe yaml; then _deps_broken PyYAML @@ -101,6 +103,8 @@ fi if compgen -G "$BSENV/lib/python3*/site-packages/typing-*-info" >/dev/null; then _as_blocks "$BSENV/bin/pip" uninstall --quiet --yes typing || true fi +# Released before step 4: install-updater.sh takes the venv lock itself and would wait on ours. +exec 201<&- # 4. Updater stack (local files only - works offline). # Verify each critical artifact: an old-installer box can have the service but no apt helper. diff --git a/scripts/install-updater.sh b/scripts/install-updater.sh index cfdf55db..ee75b616 100755 --- a/scripts/install-updater.sh +++ b/scripts/install-updater.sh @@ -228,6 +228,8 @@ echo_ok "post-merge hook installed" echo_info "Installing Python requirements ..." # Skip pip's PyPI self-check: it stalls every install on an offline box and only prints a notice. export PIP_DISABLE_PIP_VERSION_CHECK=1 +# Shared with bs-bootstrap and post-merge: a concurrent pip crashes when the pip pin below replaces pip under it. +{ exec 201<"$BSENV" && flock -w 300 201; } || echo_warn "Venv lock not acquired (busy 300s or no venv); installing unlocked" # Older runs of this script ran pip as root; hand those files back or every pip-as-blocks run fails. [ -d "$BSENV" ] && sudo find "$BSENV" ! -user "$_BSENV_USER" -exec chown -h "$_BSENV_USER" {} + || true # Pinned so every box runs the pip CI tested; best-effort, the venv's stock pip installs the pins too. @@ -254,6 +256,7 @@ if _as_blocks "$BSENV/bin/pip" install --quiet uv==0.12.24; then else echo_warn "uv install FAILED; Spoolman provisioning fails until it is installed" fi +exec 201<&- echo_info "Pre-generating framebuffer splash cache ..." _as_blocks "$BSENV/bin/python3.11" "$SCRIPT_PATH/bs-splash.py" --precompute 2>/dev/null || true diff --git a/scripts/post-merge b/scripts/post-merge index a0cda8d8..9c289703 100755 --- a/scripts/post-merge +++ b/scripts/post-merge @@ -29,6 +29,8 @@ REQS_HASH=$(md5sum "$REQS" | cut -d' ' -f1) SENTINEL="$BSENV/.blockscreen-reqs-hash" ATTEMPT="$BSENV/.blockscreen-reqs-attempt" REINSTALL="" +# Shared with bs-bootstrap and install-updater.sh; released before step 3, which runs install-updater.sh. +{ exec 201<"$BSENV" && flock -w 300 201; } || echo "[post-merge] venv lock not acquired (busy 300s or no venv) - continuing unlocked" # A truncated package (interrupted pip, power cut) still imports and yields only its dunders. if ! _as_blocks "$BSENV/bin/python3.11" -c "import yaml as _m; assert len(dir(_m)) > 15" >/dev/null 2>&1; then echo "[post-merge] PyYAML missing or broken - repairing before the pinned set" @@ -71,6 +73,7 @@ if [ ! -f "$SENTINEL" ] || [ "$(cat "$SENTINEL")" != "$REQS_HASH" ]; then fi fi fi +exec 201<&- # 2. Sync all service files so start.sh finds them already in place. bs_sync_service_files "$SCRIPT_PATH" post-merge diff --git a/tests/storage/test_usb_controller_unit.py b/tests/storage/test_usb_controller_unit.py new file mode 100644 index 00000000..4321f11f --- /dev/null +++ b/tests/storage/test_usb_controller_unit.py @@ -0,0 +1,91 @@ +"""USBManager.close must not let the queued `finished` restart the monitor.""" + +import importlib.util +import pathlib +import sys +import types +from unittest.mock import MagicMock + +import pytest +from PyQt6 import QtCore + +_STORAGE = pathlib.Path(__file__).resolve().parents[2] / "BlocksScreen/devices/storage" +_PKG = "_usb_controller_under_test" + + +class _FakeUdisks(QtCore.QObject): + """UDisksDBusAsync signal surface, no D-Bus or thread.""" + + Priority = QtCore.QThread.Priority + hardware_detected = QtCore.pyqtSignal(str) + hardware_removed = QtCore.pyqtSignal(str) + device_added = QtCore.pyqtSignal(str, dict) + device_removed = QtCore.pyqtSignal(str, str) + device_mounted = QtCore.pyqtSignal(str, str) + device_unmounted = QtCore.pyqtSignal(str) + started = QtCore.pyqtSignal() + finished = QtCore.pyqtSignal() + + def __init__(self, parent=None, gcodes_dir=None): + super().__init__(parent) + self.active = False + self.starts = 0 + + def start(self, _priority=None): + self.starts += 1 + self.active = True + + def close(self): + self.active = False + + +@pytest.fixture +def usb_controller(monkeypatch): + """Load usb_controller by path with udisks2 and BannerPopup stubbed.""" + pkg = types.ModuleType(_PKG) + pkg.__path__ = [str(_STORAGE)] + monkeypatch.setitem(sys.modules, _PKG, pkg) + monkeypatch.setitem( + sys.modules, + f"{_PKG}.udisks2", + types.SimpleNamespace(UDisksDBusAsync=_FakeUdisks), + ) + monkeypatch.setitem( + sys.modules, + "lib.panels.widgets.Common.bannerPopup", + types.SimpleNamespace(BannerPopup=MagicMock), + ) + spec = importlib.util.spec_from_file_location( + f"{_PKG}.usb_controller", _STORAGE / "usb_controller.py" + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_finished_after_close_does_not_restart(qapp, usb_controller): + parent = QtCore.QObject() + mgr = usb_controller.USBManager(parent, gcodes_dir=None) + assert mgr.udisks.starts == 1 + mgr.close() + mgr.udisks.finished.emit() + assert mgr.udisks.starts == 1 + + +def test_finished_without_close_restarts(qapp, usb_controller): + parent = QtCore.QObject() + mgr = usb_controller.USBManager(parent, gcodes_dir=None) + mgr.udisks.close() + mgr.udisks.finished.emit() + assert mgr.udisks.starts == 2 + + +def test_pending_full_restart_dropped_on_close(qapp, usb_controller): + parent = QtCore.QObject() + mgr = usb_controller.USBManager(parent, gcodes_dir=None) + mgr.restart() + assert mgr.need_restart + mgr.close() + mgr.udisks.finished.emit() + assert mgr.udisks.starts == 1 diff --git a/tests/util/test_configurator_unit.py b/tests/util/test_configurator_unit.py index e67dd1ef..66129c33 100644 --- a/tests/util/test_configurator_unit.py +++ b/tests/util/test_configurator_unit.py @@ -524,6 +524,25 @@ def test_src_empty_file(self, tmp_path): tgt = _write(tmp_path / "vars_target.cfg", "[g]\nk: v\n") assert mgr.merge_cfg(src, tgt) is True + def test_missing_option_lands_in_its_own_section(self, tmp_path): + mgr = _make_manager(tmp_path) + src = _write(tmp_path / "s.cfg", "[server]\na: 1\nb: 2\n[other]\nc: 3\n") + tgt = _write(tmp_path / "t.cfg", "[server]\na: 1\n[other]\nc: 3\n") + assert mgr.merge_cfg(src, tgt) is True + result = configparser.ConfigParser() + result.read_string(tgt.read_text()) + assert result.get("server", "b") == "2" + assert not result.has_option("other", "b") + + def test_missing_option_header_without_newline(self, tmp_path): + mgr = _make_manager(tmp_path) + src = _write(tmp_path / "s.cfg", "[Variables]\na = 1\n") + tgt = _write(tmp_path / "t.cfg", "[Variables]") + assert mgr.merge_cfg(src, tgt) is True + result = configparser.ConfigParser() + result.read_string(tgt.read_text()) + assert result.get("Variables", "a") == "1" + # ========================================================================= # merge_cfg — marker-aware (printer.cfg, SAVE_CONFIG) @@ -832,6 +851,22 @@ def test_variables_cfg_differs_merge_called(self, tmp_path): assert result.get("g", "opt_a") == "existing" assert result.get("g", "opt_b") == "repo" + def test_seed_only_file_kept_when_present(self, tmp_path): + mgr = _make_manager(tmp_path, cpy_files=["moonraker.conf"]) + src = _write(mgr.repo / "moonraker.conf", "[server]\n") + tgt = _write(mgr.config_dir / "moonraker.conf", "[server]\n# patched\n") + mgr.repo_fi_name = {"moonraker.conf": [src]} + mgr.config_fi_name = {"moonraker.conf": [tgt]} + mgr._cpy_cfg_files() + assert tgt.read_text() == "[server]\n# patched\n" + + def test_seed_only_file_seeded_when_missing(self, tmp_path): + mgr = _make_manager(tmp_path, cpy_files=["moonraker.conf"]) + src = _write(mgr.repo / "moonraker.conf", "[server]\n") + mgr.repo_fi_name = {"moonraker.conf": [src]} + mgr._cpy_cfg_files() + assert (mgr.config_dir / "moonraker.conf").read_text() == "[server]\n" + # ========================================================================= # sync — smoke test diff --git a/updater/components.yaml b/updater/components.yaml index 2ef040dd..8aef7f94 100644 --- a/updater/components.yaml +++ b/updater/components.yaml @@ -76,6 +76,8 @@ components: path: ~/Spoolman url: https://github.com/Donkie/Spoolman branch: master + # v0.27.0: upstream calls master untested and its DB migrations one-way; bump per release. + version: bed3f14ee77a3a571d31b14215d54b0b88a7519c service: Spoolman.service reset_mode: hard order: 21 From 5bb06cbbeabfa44032df2aa0f007e70f934bc996 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Wed, 21 Oct 2026 15:39:58 +0100 Subject: [PATCH 2/3] fix(updater): skip post-merge pip when venv is busy, keep multi-line values on merge --- .../tools/configuration_manager/configurator.py | 11 +++++++++-- scripts/post-merge | 10 ++++++---- tests/util/test_configurator_unit.py | 17 +++++++++++++++++ 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/BlocksScreen/tools/configuration_manager/configurator.py b/BlocksScreen/tools/configuration_manager/configurator.py index caf56e33..27f7a6d4 100644 --- a/BlocksScreen/tools/configuration_manager/configurator.py +++ b/BlocksScreen/tools/configuration_manager/configurator.py @@ -27,6 +27,11 @@ _logger = logging.getLogger(__name__) +def _opt_line(opt: str, val: str) -> str: + """Option line; continuation lines indented so a multi-line value stays one option.""" + return f"{opt}= " + val.replace("\n", "\n ") + + def _timestamp() -> str: """Return the current timestamp in ISO8601 format @@ -572,7 +577,9 @@ def merge_cfg( if m and m.group("header") in inserts: opts = inserts.pop(m.group("header")) tlines[i] = tlines[i].rstrip("\n") + "\n" - tlines[i + 1 : i + 1] = [f"{o}= {v}\n" for o, v in opts] + tlines[i + 1 : i + 1] = [ + _opt_line(o, v) + "\n" for o, v in opts + ] base = "".join(tlines) for opt, val in src_cfg.defaults().items(): @@ -592,7 +599,7 @@ def merge_cfg( for section, opts in appendix: lines.append(f"[{section}]") for opt, val in opts: - lines.append(f"{opt}= {val}") + lines.append(_opt_line(opt, val)) text = "\n".join(lines) if base and not base.endswith("\n"): base += "\n" diff --git a/scripts/post-merge b/scripts/post-merge index 9c289703..6703fb94 100755 --- a/scripts/post-merge +++ b/scripts/post-merge @@ -30,9 +30,11 @@ SENTINEL="$BSENV/.blockscreen-reqs-hash" ATTEMPT="$BSENV/.blockscreen-reqs-attempt" REINSTALL="" # Shared with bs-bootstrap and install-updater.sh; released before step 3, which runs install-updater.sh. -{ exec 201<"$BSENV" && flock -w 300 201; } || echo "[post-merge] venv lock not acquired (busy 300s or no venv) - continuing unlocked" +# Busy = skip pip, not wait: the updater kills this hook at HOOK_TIMEOUT (600s); bs-bootstrap retries next boot. +_VENV_LOCKED=0 +{ exec 201<"$BSENV" && flock -w 60 201 && _VENV_LOCKED=1; } || echo "[post-merge] venv lock not acquired (busy 60s or no venv) - skipping pip" # A truncated package (interrupted pip, power cut) still imports and yields only its dunders. -if ! _as_blocks "$BSENV/bin/python3.11" -c "import yaml as _m; assert len(dir(_m)) > 15" >/dev/null 2>&1; then +if [ "$_VENV_LOCKED" = 1 ] && ! _as_blocks "$BSENV/bin/python3.11" -c "import yaml as _m; assert len(dir(_m)) > 15" >/dev/null 2>&1; then echo "[post-merge] PyYAML missing or broken - repairing before the pinned set" # --force-reinstall: a gutted package keeps intact dist-info, so a plain install reports it already satisfied. _as_blocks "$BSENV/bin/pip" install --quiet --force-reinstall --no-cache-dir \ @@ -41,12 +43,12 @@ if ! _as_blocks "$BSENV/bin/python3.11" -c "import yaml as _m; assert len(dir(_m rm -f "$SENTINEL" "$ATTEMPT" 2>/dev/null || true fi # A zero-length .so is never legitimate: it means the filesystem lost writes mid-flush. -if [ -z "$REINSTALL" ] && [ -n "$(find "$BSENV/lib" -name '*.so' -size 0 -print -quit 2>/dev/null)" ]; then +if [ "$_VENV_LOCKED" = 1 ] && [ -z "$REINSTALL" ] && [ -n "$(find "$BSENV/lib" -name '*.so' -size 0 -print -quit 2>/dev/null)" ]; then echo "[post-merge] truncated extension module - reinstalling venv" REINSTALL="--force-reinstall --no-cache-dir" rm -f "$SENTINEL" "$ATTEMPT" 2>/dev/null || true fi -if [ ! -f "$SENTINEL" ] || [ "$(cat "$SENTINEL")" != "$REQS_HASH" ]; then +if [ "$_VENV_LOCKED" = 1 ] && { [ ! -f "$SENTINEL" ] || [ "$(cat "$SENTINEL")" != "$REQS_HASH" ]; }; then echo "[post-merge] requirements changed - pre-installing deps (this may take a few minutes) ..." # shellcheck disable=SC2086 if _as_blocks "$BSENV/bin/pip" install --quiet $REINSTALL \ diff --git a/tests/util/test_configurator_unit.py b/tests/util/test_configurator_unit.py index 66129c33..f4f59876 100644 --- a/tests/util/test_configurator_unit.py +++ b/tests/util/test_configurator_unit.py @@ -543,6 +543,23 @@ def test_missing_option_header_without_newline(self, tmp_path): result.read_string(tgt.read_text()) assert result.get("Variables", "a") == "1" + def test_missing_option_goes_into_last_copy_of_repeated_section(self, tmp_path): + mgr = _make_manager(tmp_path) + src = _write(tmp_path / "s.cfg", "[a]\nx: 1\ny: 2\n") + tgt = _write(tmp_path / "t.cfg", "[a]\nx: 1\n[b]\nz: 3\n[a]\nw: 4\n") + assert mgr.merge_cfg(src, tgt) is True + assert tgt.read_text() == "[a]\nx: 1\n[b]\nz: 3\n[a]\ny= 2\nw: 4\n" + + def test_missing_multiline_value_stays_one_option(self, tmp_path): + mgr = _make_manager(tmp_path) + src = _write(tmp_path / "s.cfg", "[a]\nx: 1\nfiles:\n p.cfg\n m.conf\n") + tgt = _write(tmp_path / "t.cfg", "[a]\nx: 1\n[b]\nz: 3\n") + assert mgr.merge_cfg(src, tgt) is True + result = configparser.ConfigParser() + result.read_string(tgt.read_text()) + assert result.get("a", "files").split() == ["p.cfg", "m.conf"] + assert result.options("b") == ["z"] + # ========================================================================= # merge_cfg — marker-aware (printer.cfg, SAVE_CONFIG) From abd29054a23ee4a69cbd96b43602059a14595801 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Wed, 21 Oct 2026 16:44:41 +0100 Subject: [PATCH 3/3] fix(bootstrap): reinstall requirements a skipped post-merge pip left behind --- BlocksScreen/screensaver.py | 2 -- scripts/bs-bootstrap.sh | 61 +++++++++++++++++++++---------------- 2 files changed, 35 insertions(+), 28 deletions(-) diff --git a/BlocksScreen/screensaver.py b/BlocksScreen/screensaver.py index 1d40badf..6a5e4e80 100644 --- a/BlocksScreen/screensaver.py +++ b/BlocksScreen/screensaver.py @@ -1,5 +1,3 @@ -"""Inactivity screensaver: DPMS display blanking and wake-touch suppression.""" - import helper_methods as helper_methods from PyQt6 import QtCore, QtWidgets diff --git a/scripts/bs-bootstrap.sh b/scripts/bs-bootstrap.sh index 1f3f780d..e1d1286a 100755 --- a/scripts/bs-bootstrap.sh +++ b/scripts/bs-bootstrap.sh @@ -68,36 +68,45 @@ if [ -z "$REINSTALL" ] && [ -n "$(find "$BSENV/lib" -name '*.so' -size 0 -print fi # 3. Full pinned requirements when the set changed (also done by post-merge). -REQS_HASH=$(md5sum "$REQS" | cut -d' ' -f1) -if [ ! -f "$SENTINEL" ] || [ "$(cat "$SENTINEL")" != "$REQS_HASH" ]; then - # shellcheck disable=SC2086 - if _as_blocks "$BSENV/bin/pip" install --quiet $REINSTALL \ - --only-binary :all: \ - --no-binary sdbus,sdbus-networkmanager \ - --upgrade-strategy=only-if-needed \ - -r "$BS_PATH/scripts/requirements.txt"; then - echo "$REQS_HASH" >"$SENTINEL" - chown "$_BSENV_USER" "$SENTINEL" 2>/dev/null || true - rm -f "$ATTEMPT" 2>/dev/null || true - else - # pip resolves -r atomically: a single dep without an aarch64 wheel - # rejects the whole set, the sentinel never advances, and bootstrap - # re-thrashes this install on every boot. Bound the retries - keep - # trying for a few boots (covers transient/offline failures), then - # accept the partial venv so a deterministically-failing set stops - # thrashing the boot path. - _prev=$(cut -d' ' -f1 "$ATTEMPT" 2>/dev/null || true) - _cnt=$(cut -d' ' -f2 "$ATTEMPT" 2>/dev/null || true) - case "$_cnt" in '' | *[!0-9]*) _cnt=0 ;; esac - if [ "$_prev" = "$REQS_HASH" ]; then _cnt=$((_cnt + 1)); else _cnt=1; fi - echo "$REQS_HASH $_cnt" >"$ATTEMPT" - chown "$_BSENV_USER" "$ATTEMPT" 2>/dev/null || true - if [ "$_cnt" -ge 3 ]; then - echo "[bs-bootstrap] requirements failed ${_cnt}x; giving up to stop boot thrash (venv left as-is)" +_install_reqs() { + REQS_HASH=$(md5sum "$REQS" | cut -d' ' -f1) + if [ ! -f "$SENTINEL" ] || [ "$(cat "$SENTINEL")" != "$REQS_HASH" ]; then + # shellcheck disable=SC2086 + if _as_blocks "$BSENV/bin/pip" install --quiet $REINSTALL \ + --only-binary :all: \ + --no-binary sdbus,sdbus-networkmanager \ + --upgrade-strategy=only-if-needed \ + -r "$BS_PATH/scripts/requirements.txt"; then echo "$REQS_HASH" >"$SENTINEL" chown "$_BSENV_USER" "$SENTINEL" 2>/dev/null || true + rm -f "$ATTEMPT" 2>/dev/null || true + else + # pip resolves -r atomically: a single dep without an aarch64 wheel + # rejects the whole set, the sentinel never advances, and bootstrap + # re-thrashes this install on every boot. Bound the retries - keep + # trying for a few boots (covers transient/offline failures), then + # accept the partial venv so a deterministically-failing set stops + # thrashing the boot path. + _prev=$(cut -d' ' -f1 "$ATTEMPT" 2>/dev/null || true) + _cnt=$(cut -d' ' -f2 "$ATTEMPT" 2>/dev/null || true) + case "$_cnt" in '' | *[!0-9]*) _cnt=0 ;; esac + if [ "$_prev" = "$REQS_HASH" ]; then _cnt=$((_cnt + 1)); else _cnt=1; fi + echo "$REQS_HASH $_cnt" >"$ATTEMPT" + chown "$_BSENV_USER" "$ATTEMPT" 2>/dev/null || true + if [ "$_cnt" -ge 3 ]; then + echo "[bs-bootstrap] requirements failed ${_cnt}x; giving up to stop boot thrash (venv left as-is)" + echo "$REQS_HASH" >"$SENTINEL" + chown "$_BSENV_USER" "$SENTINEL" 2>/dev/null || true + fi fi fi +} +_install_reqs +# post-merge skips pip while we hold the venv lock: install what it merged meanwhile. +if [ "$(md5sum "$REQS" | cut -d' ' -f1)" != "$REQS_HASH" ]; then + echo "[bs-bootstrap] requirements changed during install - installing again" + REINSTALL="" + _install_reqs fi # pip -r never removes a dropped pin; the glob keeps this free on every start once the stale backport is gone. if compgen -G "$BSENV/lib/python3*/site-packages/typing-*-info" >/dev/null; then