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.
184 lines
6.5 KiB
Python
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
|