Files
hermes-agent/pm/libatomic.py
kshitijk4poor 47e6c2ebb5 fix(update): keep the completion parent stdlib-only around the libatomic pre-install
run_completion runs in the OLD process after the checkout swap, and its
contract is never to import application helpers from the replacement tree.
Importing pm.libatomic there crashed the update when the new tree lacked
the module (downgrade or older pin) and on architectures where
current_target() raises. Run the pre-install as its own process from the
new tree, ignore its outcome, and let the in-lock repair report.

Also: gate on an openable /dev/tty first (cheapest check, and the detached
child exits immediately), treat an unsupported architecture as "skip",
bound the sudo -n probe, and drop the timeout on the interactive install
so a slow prompt or mirror is never killed mid-dpkg (it runs outside PM's
lock). Found in the final-head review of #132664.
2026-10-04 19:19:41 +05:30

184 lines
6.5 KiB
Python

"""Install the host libatomic package required by official Linux Node.
Two entry points: install_before_lock() lets an interactive update install
the package (asking for sudo) before PM takes its install lock, and
try_install_libatomic() is the non-interactive repair run from Nodejs.repair_staged_verification().
Package verification itself remains diagnostic and side-effect free.
"""
from __future__ import annotations
import os
import platform
import shlex
import shutil
import subprocess
_INSTALLERS: dict[str, tuple[str, ...]] = {
"apt-get": ("apt-get", "install", "-y", "libatomic1"),
"dnf": ("dnf", "install", "-y", "libatomic"),
"yum": ("yum", "install", "-y", "libatomic"),
"zypper": ("zypper", "--non-interactive", "install", "libatomic1"),
"pacman": ("pacman", "-S", "--needed", "--noconfirm", "gcc-libs"),
"apk": ("apk", "add", "libatomic"),
}
_DEBIAN = {"debian", "ubuntu", "linuxmint", "pop", "raspbian", "kali", "elementary"}
_RPM = {"rhel", "fedora", "centos", "rocky", "almalinux", "ol", "oracle", "amzn"}
_ARCH = {"arch", "manjaro", "endeavouros", "garuda", "artix"}
# One attempt per process: every root that depends on node (npm, browser
# tools) re-stages it after a failure, and each would otherwise re-run the
# package manager under PM's install lock. A later process retries.
_ATTEMPT: tuple[bool, str] | None = None
def _release_tokens() -> set[str]:
try:
fields = platform.freedesktop_os_release()
except OSError:
return set()
blob = f"{fields.get('ID', '')} {fields.get('ID_LIKE', '')}".lower()
return set(blob.replace(",", " ").split())
def _manager_order(tokens: set[str]) -> tuple[str, ...]:
if tokens & _DEBIAN:
return ("apt-get",)
if tokens & _RPM:
return ("dnf", "yum")
if any("suse" in token or token.startswith("sles") for token in tokens):
return ("zypper",)
if tokens & _ARCH:
return ("pacman",)
if "alpine" in tokens:
return ("apk",)
return ("dnf", "yum", "apt-get", "zypper", "pacman", "apk")
def _host_install_command() -> tuple[str, ...] | None:
for manager in _manager_order(_release_tokens()):
if shutil.which(manager):
return _INSTALLERS[manager]
return None
def _is_root() -> bool:
return bool(hasattr(os, "geteuid") and os.geteuid() == 0)
def _command_plan(command: tuple[str, ...]) -> tuple[list[str] | None, str, str]:
"""Return auto-repair argv, its display form, and a runnable manual remedy.
Host-package repair runs while PM owns its publication lock, so it must never
wait on an interactive privilege prompt. Non-root repair therefore uses
``sudo -n`` even on a TTY. The manual remedy may use normal ``sudo``
outside that lock. If sudo is unavailable, don't advertise it at all.
"""
if _is_root():
argv = list(command)
shown = shlex.join(argv)
return argv, shown, shown
sudo = shutil.which("sudo")
if sudo:
argv = [sudo, "-n", *command]
return argv, shlex.join(["sudo", "-n", *command]), shlex.join(["sudo", *command])
return None, "", f"as root: {shlex.join(command)}"
def install_before_lock() -> None:
"""Install the libatomic package interactively before PM takes its lock.
Inside the lock the repair may only use ``sudo -n``, so a password-sudo
host needs the password asked here, where a prompt cannot wedge PM. Run
only on a glibc Linux host still missing libatomic.so.1, with a
controlling terminal sudo can read from (detached children, gateway and
Desktop updates have none), and only when sudo would actually ask.
"""
import ctypes
import sys
from pm.store import MUSL_TARGETS, current_target
if not sys.platform.startswith("linux") or _is_root() or not _has_terminal():
return
try:
target = current_target()
except RuntimeError: # unsupported architecture: PM reports it elsewhere
return
if target in MUSL_TARGETS or target.endswith("-bionic"):
return
try:
ctypes.CDLL("libatomic.so.1")
return
except OSError:
pass
command = _host_install_command()
if command is None:
return
argv = _command_plan(command)[0]
if not argv:
return
sudo = argv[0]
if _run([sudo, "-n", "true"], timeout=10):
return # the non-interactive repair will succeed on its own
print("→ Node.js needs libatomic.so.1; sudo may ask for your password to install it", flush=True)
# Outside PM's lock: no timeout, so a slow prompt or mirror is never killed mid-dpkg.
_install([sudo], command, stdin=None, timeout=None)
def _has_terminal() -> bool:
try:
os.close(os.open("/dev/tty", os.O_RDWR | os.O_NOCTTY))
except OSError:
return False
return True
def _run(argv: list[str], stdin: int | None = subprocess.DEVNULL, timeout: float | None = 300) -> bool:
try:
return subprocess.run(argv, stdin=stdin, timeout=timeout, check=False).returncode == 0
except (OSError, subprocess.TimeoutExpired):
return False
def _install(prefix: list[str], command: tuple[str, ...], stdin: int | None = subprocess.DEVNULL,
timeout: float | None = 300) -> bool:
if _run([*prefix, *command], stdin, timeout):
return True
if command[0] != "apt-get":
return False
# Minimal Debian/Ubuntu images ship empty package lists.
return (_run([*prefix, "apt-get", "update", "-qq"], stdin, timeout)
and _run([*prefix, *command], stdin, timeout))
def try_install_libatomic() -> tuple[bool, str]:
"""Try the distro-native libatomic package; return (attempt_succeeded, hint).
Success means the package-manager command completed successfully. The Node
smoke probe remains the authority and is always repeated by the caller.
"""
global _ATTEMPT
if _ATTEMPT is None:
_ATTEMPT = _attempt()
return _ATTEMPT
def _attempt() -> tuple[bool, str]:
command = _host_install_command()
if command is None:
return (
False,
"official Node needs libatomic.so.1; install the distro package that provides it and rerun",
)
argv, attempt_display, remedy_display = _command_plan(command)
remedy = f"install the missing runtime library with {remedy_display} and rerun"
if argv is None:
return False, remedy
print(f"→ Node needs libatomic.so.1; trying non-interactive {attempt_display}", flush=True)
return _install(argv[: len(argv) - len(command)], command), remedy