From 2521e1c97216684101628b7942ad66833d6330b9 Mon Sep 17 00:00:00 2001 From: I4cDeath Date: Sat, 18 Apr 2026 23:26:18 +0800 Subject: [PATCH] fix: AppImage crash from vendored pydantic-core ABI mismatch (closes #13) The bundled pydantic_core .so extension was compiled for one Python minor version but failed on systems running a different version (e.g. built with 3.12 but user has 3.13). - Add _vendor_compat.py bootstrap that detects ABI-incompatible vendored native extensions and removes them at startup so Python falls back to system site-packages - Pin Python 3.12 in CI build workflow via actions/setup-python - Add python3-pydantic to deb/rpm package dependencies - Clean up vendor script (remove __pycache__, trim dist-info) - Ensure PYTHONNOUSERSITE is unset so system packages are reachable - Bump version to 0.8.11 Made-with: Cursor --- .github/workflows/build-release.yml | 4 ++ electron/main.ts | 8 ++- electron/package.json | 6 +-- electron/scripts/vendor-python-deps.sh | 27 +++++++++- pyproject.toml | 2 +- src/game_setup_hub/_vendor_compat.py | 70 ++++++++++++++++++++++++++ src/game_setup_hub/api.py | 2 + 7 files changed, 112 insertions(+), 7 deletions(-) create mode 100644 src/game_setup_hub/_vendor_compat.py diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index ca75268..70af96a 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -17,6 +17,10 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: pnpm/action-setup@v5 with: version: latest diff --git a/electron/main.ts b/electron/main.ts index 79c1306..0027b5b 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -64,7 +64,13 @@ function getPythonCommand(): { cmd: string; args: string[]; env: NodeJS.ProcessE pyPathParts.push(vendorDir); } pyPathParts.push(srcDir); - env.PYTHONPATH = pyPathParts.join(":") + (env.PYTHONPATH ? `:${env.PYTHONPATH}` : ""); + if (env.PYTHONPATH) { + pyPathParts.push(env.PYTHONPATH); + } + env.PYTHONPATH = pyPathParts.join(":"); + // Ensure system site-packages are available as fallback for native + // extensions (.so) that may not match the vendored Python version. + env.PYTHONNOUSERSITE = ""; return { cmd: "python3", args: ["-m", "game_setup_hub.api", "--port", "0"], diff --git a/electron/package.json b/electron/package.json index 045d487..c2b68bf 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "protonshift", - "version": "0.8.10", + "version": "0.8.11", "description": "Linux game configuration toolkit", "main": "dist/main.js", "scripts": { @@ -85,11 +85,11 @@ } }, "deb": { - "depends": ["python3 (>= 3.12)"], + "depends": ["python3 (>= 3.12)", "python3-pydantic"], "maintainer": "I4cTime " }, "rpm": { - "depends": ["python3 >= 3.12"], + "depends": ["python3 >= 3.12", "python3-pydantic"], "fpm": ["--rpm-summary", "Linux game configuration toolkit"] }, "flatpak": { diff --git a/electron/scripts/vendor-python-deps.sh b/electron/scripts/vendor-python-deps.sh index 90b5328..e6b7cc9 100755 --- a/electron/scripts/vendor-python-deps.sh +++ b/electron/scripts/vendor-python-deps.sh @@ -1,8 +1,31 @@ #!/usr/bin/env bash +# Vendor Python runtime dependencies for AppImage/deb/rpm packaging. +# +# Native extensions (.so) are version-locked to the build Python. +# The Electron main process appends system site-packages as a fallback +# so users on a different Python minor version still get native deps +# from their system packages. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" TARGET="${ROOT}/python-vendor" REQ="${ROOT}/python-runtime-requirements.txt" + rm -rf "${TARGET}" -python3 -m pip install -r "${REQ}" -t "${TARGET}" --upgrade -echo "Vendored Python deps into ${TARGET}" +python3 -m pip install \ + -r "${REQ}" \ + -t "${TARGET}" \ + --upgrade \ + --no-cache-dir + +# Remove unnecessary bloat from vendor dir +find "${TARGET}" -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true +find "${TARGET}" -type d -name "*.dist-info" -exec sh -c ' + for d; do + # Keep METADATA and top_level.txt, delete the rest + find "$d" -maxdepth 1 -type f ! -name METADATA ! -name top_level.txt ! -name RECORD -delete 2>/dev/null + done +' _ {} + 2>/dev/null || true + +PY_VER="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" +SO_COUNT="$(find "${TARGET}" -name '*.so' | wc -l)" +echo "Vendored Python deps into ${TARGET} (Python ${PY_VER}, ${SO_COUNT} native extensions)" diff --git a/pyproject.toml b/pyproject.toml index 6c844f9..8460078 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "protonshift" -version = "0.8.10" +version = "0.8.11" description = "Linux game configuration toolkit: GPU, launch options, Proton, env vars" readme = "README.md" requires-python = ">=3.12" diff --git a/src/game_setup_hub/_vendor_compat.py b/src/game_setup_hub/_vendor_compat.py new file mode 100644 index 0000000..690676b --- /dev/null +++ b/src/game_setup_hub/_vendor_compat.py @@ -0,0 +1,70 @@ +"""Ensure vendored native extensions are compatible with the running Python. + +When the app is packaged (AppImage/deb/rpm), Python dependencies are vendored +into a directory added to PYTHONPATH. Native extensions (.so) are ABI-locked +to the Python version used at build time. If the user runs a different Python +minor version, those .so files won't load. + +This module detects the mismatch early and removes incompatible vendored +packages from sys.path so Python falls back to system site-packages. +""" + +from __future__ import annotations + +import sys +import sysconfig +from pathlib import Path + +_NATIVE_PACKAGES = [ + "pydantic_core", + "uvloop", + "httptools", + "watchfiles", + "yaml", # PyYAML +] + +_SOABI = sysconfig.get_config_var("SOABI") or "" + + +def _has_compatible_so(pkg_dir: Path) -> bool: + """Check if a vendored package dir has .so files matching this Python.""" + so_files = list(pkg_dir.glob("*.so")) + if not so_files: + return True # pure-Python package, always compatible + return any(_SOABI in f.name for f in so_files) + + +def fixup_vendor_path() -> None: + """Remove vendored native-extension dirs that are ABI-incompatible.""" + vendor_dirs = [ + p for p in sys.path + if "vendor" in p and Path(p).is_dir() + ] + if not vendor_dirs: + return + + for vendor_dir in vendor_dirs: + vp = Path(vendor_dir) + for pkg_name in _NATIVE_PACKAGES: + pkg_dir = vp / pkg_name + if pkg_dir.is_dir() and not _has_compatible_so(pkg_dir): + _remove_vendored_package(vp, pkg_name) + + +def _remove_vendored_package(vendor_dir: Path, pkg_name: str) -> None: + """Remove a single incompatible package from the vendor dir at runtime.""" + import shutil + + pkg_dir = vendor_dir / pkg_name + if pkg_dir.exists(): + shutil.rmtree(pkg_dir, ignore_errors=True) + + for dist_info in vendor_dir.glob(f"{pkg_name}-*.dist-info"): + shutil.rmtree(dist_info, ignore_errors=True) + + # Clear any cached import state + if pkg_name in sys.modules: + del sys.modules[pkg_name] + + +fixup_vendor_path() diff --git a/src/game_setup_hub/api.py b/src/game_setup_hub/api.py index 30771d2..16c802d 100644 --- a/src/game_setup_hub/api.py +++ b/src/game_setup_hub/api.py @@ -2,6 +2,8 @@ from __future__ import annotations +import game_setup_hub._vendor_compat # noqa: F401 # must be first + import argparse import asyncio import socket