Skip to content

Commit 542bc10

Browse files
authored
fix: guard linux package native cpu baseline (#71)
1 parent ca8b61f commit 542bc10

8 files changed

Lines changed: 218 additions & 7 deletions

File tree

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ jobs:
132132
- name: Install Linux build/runtime libraries
133133
run: |
134134
sudo apt-get update
135-
sudo apt-get install -y --no-install-recommends rsync libgl1 libegl1 libglib2.0-0 libxcb-cursor0
135+
sudo apt-get install -y --no-install-recommends rsync binutils libgl1 libegl1 libglib2.0-0 libxcb-cursor0
136136
137137
- name: Validate Linux installer scripts
138138
run: bash -n build_linux_app.sh installer/build_linux_app.sh installer/install-linux.sh

RELEASE_NOTES.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,15 @@ This release adds Thoth's **Buddy companion foundation**, a local-first animated
3939

4040
- **Linux launcher install-path fix** — the generated Linux launcher resolves installed symlink chains before computing the app root, so `~/.local/bin/thoth` starts the packaged app from `~/.local/share/thoth/current`; release CI smokes through the installed user launcher path.
4141
- **Linux packaged startup resilience** — packaged Linux launches now report startup log tails, child-process exit details, configurable `THOTH_STARTUP_TIMEOUT`, and targeted hints for native OpenCV/FAISS/NumPy dependency failures. Camera and screenshot capture degrade gracefully if OpenCV/MSS cannot import instead of blocking app startup.
42+
- **Linux native CPU-baseline compatibility** — packaged Python builds now keep NumPy below the newer Linux x86_64 wheel line that can require `x86-64-v2` CPU instructions, and Linux package builds scan embedded native libraries for `x86-64-v2/v3/v4` requirements before upload to prevent startup crashes on older x86_64 machines.
4243
- **Linux installer UX hardening** — source-checkout builds support the root-level `bash build_linux_app.sh <version>` support command, install success messages print `~/.local/bin/thoth` when `~/.local/bin` is not on `PATH`, and maintainer docs distinguish unreleased tarball testing from the one-line installer that resolves published GitHub Release assets.
4344
- **Optional native package diagnostics** — startup detects installed-but-broken optional native packages such as TorchCodec, logs a concrete recovery command, and makes Transformers treat broken TorchCodec as unavailable instead of letting optional audio/video helpers crash Thoth during startup.
4445
- **Windows embedded-Python repair hardening** — Windows installer repair/upgrade replaces the bundled `{app}\python` runtime before copying the new payload, preventing manually installed or corrupted packages from surviving an over-the-top reinstall.
4546

4647
### Tests & Release Checks
4748

4849
- **Buddy coverage** — focused tests cover core event/config/asset behavior, Hatch motion activation, UTF-8 config loading, UI wiring, event source hooks, runtime fallback behavior, dockable in-app behavior, built-in motion semantics, and packaging inclusion. Manual-style browser smokes verify docked, undocked, and overlay playback from the bundled pack.
49-
- **Reliability coverage** — startup hardening tests cover broken TorchCodec detection, Linux native dependency recovery hints, launcher log-tail diagnostics, Windows installer embedded-Python replacement, app import smoke, Settings -> Models catalog bounds and picker guidance, status-tool model validation, safe timer cleanup, and installed Linux launcher symlink/default invocation resolution.
50+
- **Reliability coverage** — startup hardening tests cover broken TorchCodec detection, Linux native dependency recovery hints, NumPy `x86-64-v2` startup failures, launcher log-tail diagnostics, Windows installer embedded-Python replacement, app import smoke, Settings -> Models catalog bounds and picker guidance, status-tool model validation, safe timer cleanup, and installed Linux launcher symlink/default invocation resolution.
5051
- **Provider/Vision coverage** — provider tests cover ChatGPT / Codex Vision Quick Choice capability retention and Codex Responses multimodal image payload preservation.
5152
- **Release smoke** — release and CI workflows build Windows, macOS, and Linux artifacts for v3.21.0, run focused startup/provider suites before installer builds, and smoke the installed Linux launcher path.
5253
- **Test layout cleanup** — root-level test files now live under `tests/`, pytest discovers that folder by default, CI/release workflows call the moved paths, and installer regressions assert the `tests/` tree is not shipped in Windows, Linux, or macOS packages.
@@ -66,7 +67,7 @@ This release adds Thoth's **Buddy companion foundation**, a local-first animated
6667
| `buddy/`, `static/buddy/`, `ui/buddy.py` | Buddy event/config/runtime surfaces, bundled motion packs, in-app docked/undocked UI, and desktop overlay route/runtime assets |
6768
| `ui/settings.py`, `ui/model_catalog.py`, `providers/selection.py`, `providers/catalog.py`, `providers/codex.py`, `models.py` | Settings -> Models stability, picker clarity, Codex Vision capability retention, and provider/model catalog refinements |
6869
| `providers/transports/codex_responses.py`, `vision.py`, `tools/thoth_status_tool.py` | Codex multimodal image payload preservation, startup-safe Vision capture backends, and controlled Brain/Vision setting updates |
69-
| `launcher.py`, `startup_diagnostics.py`, `installer/thoth_setup.iss`, `installer/install_deps.bat` | Startup diagnostics, Linux readiness failure context, Windows embedded-Python repair, and optional native package recovery hints |
70+
| `launcher.py`, `startup_diagnostics.py`, `requirements.txt`, `installer/thoth_setup.iss`, `installer/install_deps.bat` | Startup diagnostics, Linux readiness failure context, Linux native CPU-baseline packaging guard, Windows embedded-Python repair, and optional native package recovery hints |
7071
| `installer/build_linux_app.sh`, `installer/install-linux.sh`, `build_linux_app.sh`, `.github/workflows/release.yml`, `.github/workflows/ci.yml` | Linux launcher symlink resolution, root build wrapper, installed launcher smoke, and release/CI packaging checks |
7172
| `docs/RELEASING.md`, `installer/README.md`, `README.md`, `docs/ARCHITECTURE.md` | Release checklist, installer, architecture, and user-facing Linux/provider/model guidance updates |
7273
| `tests/`, `pytest.ini` | Focused startup/Linux/provider/model-selection regressions, release-smoke coverage, moved test discovery, and installer exclusion guards |

installer/build_linux_app.sh

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,12 @@ info "[2/6] Installing Python packages from requirements.txt..."
8585
"$PYTHON_PREFIX/bin/python3" -m pip install -r "$PROJECT_DIR/requirements.txt" --quiet 2>&1 | tail -5
8686
ok "Python packages installed"
8787

88+
if [ "$PACKAGE_ARCH" = "x86_64" ]; then
89+
info "Checking native CPU baselines for older x86_64 compatibility..."
90+
"$PYTHON_PREFIX/bin/python3" "$PROJECT_DIR/scripts/check_linux_native_baseline.py" --arch "$PACKAGE_ARCH"
91+
ok "Native CPU baselines are package-compatible"
92+
fi
93+
8894
if [ "$BUNDLE_PLAYWRIGHT" = "1" ]; then
8995
info "Installing Playwright Chromium into package..."
9096
export PLAYWRIGHT_BROWSERS_PATH="$PYTHON_PREFIX/playwright-browsers"

launcher.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -221,10 +221,16 @@ def _startup_failure_hints(log_text: str, python_executable: str | None = None)
221221
"Detected a FAISS native import failure during startup.",
222222
"Recovery: reinstall Thoth's packaged runtime or install the Linux libraries named in the traceback.",
223223
])
224-
if "numpy.dtype size changed" in text or "numpy.core.multiarray failed to import" in text:
224+
if (
225+
"numpy.dtype size changed" in text
226+
or "numpy.core.multiarray failed to import" in text
227+
or "numpy was built with baseline optimizations" in text
228+
or "x86_v2" in text
229+
):
225230
hints.extend([
226-
"Detected a NumPy/native wheel ABI mismatch during startup.",
227-
"Recovery: reinstall or repair Thoth so the embedded Python runtime and wheels are replaced together.",
231+
"Detected a NumPy/native wheel startup failure.",
232+
"On older x86_64 CPUs this can happen if the packaged NumPy wheel requires x86-64-v2 instructions.",
233+
"Recovery: install a Thoth Linux build that pins NumPy below the x86-64-v2 wheel line, or rebuild the Linux tarball from this checkout.",
228234
])
229235
return hints
230236

requirements.txt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,11 @@ plyer
7272
youtube-search
7373

7474
# ── Common (used directly, also pulled transitively) ────────────────────────
75-
numpy
75+
# NumPy 2.3+ Linux x86_64 wheels can require x86-64-v2 CPU instructions. Keep
76+
# packaged Python 3.12/3.13 builds on the older baseline; Python 3.14 needs the
77+
# current line while wheel support is still catching up.
78+
numpy<2.3; python_version < "3.14"
79+
numpy; python_version >= "3.14"
7680
requests
7781
pydantic
7882
pyyaml
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
#!/usr/bin/env python3
2+
"""Verify Linux package native wheels do not require a newer x86_64 baseline."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import platform
8+
import re
9+
import shutil
10+
import site
11+
import subprocess
12+
import sys
13+
from collections.abc import Iterable
14+
from pathlib import Path
15+
16+
_X86_BASELINE_MARKERS = {"X86_V2", "X86_V3", "X86_V4"}
17+
18+
19+
def _normalized_arch(value: str | None = None) -> str:
20+
arch = (value or platform.machine() or "").lower().replace("-", "_")
21+
if arch in {"amd64", "x64"}:
22+
return "x86_64"
23+
if arch == "arm64":
24+
return "aarch64"
25+
return arch
26+
27+
28+
def _numpy_cpu_baseline() -> list[str]:
29+
try:
30+
try:
31+
import numpy._core._multiarray_umath as umath
32+
except ModuleNotFoundError:
33+
import numpy.core._multiarray_umath as umath # type: ignore[no-redef]
34+
except Exception as exc:
35+
raise RuntimeError(f"could not import NumPy CPU metadata: {exc}") from exc
36+
37+
baseline = getattr(umath, "__cpu_baseline__", None)
38+
if baseline is None:
39+
raise RuntimeError("NumPy does not expose __cpu_baseline__ metadata")
40+
return [str(feature).upper().replace("-", "_") for feature in baseline]
41+
42+
43+
def _blocked_x86_baselines(features: Iterable[str]) -> list[str]:
44+
normalized = {str(feature).upper().replace("-", "_") for feature in features}
45+
return sorted(feature for feature in normalized if feature in _X86_BASELINE_MARKERS)
46+
47+
48+
def _blocked_readelf_baselines(output: str) -> list[str]:
49+
normalized = output.upper().replace("-", "_")
50+
blocked: set[str] = set()
51+
for marker in _X86_BASELINE_MARKERS:
52+
if marker in normalized:
53+
blocked.add(marker)
54+
for version in re.findall(r"X86_64_V([234])", normalized):
55+
blocked.add(f"X86_V{version}")
56+
return sorted(blocked)
57+
58+
59+
def _native_search_roots() -> list[Path]:
60+
roots = {Path(sys.prefix).resolve()}
61+
for path in site.getsitepackages():
62+
try:
63+
roots.add(Path(path).resolve())
64+
except OSError:
65+
continue
66+
return sorted(roots)
67+
68+
69+
def _native_binaries(roots: Iterable[Path]) -> list[Path]:
70+
binaries: set[Path] = set()
71+
for root in roots:
72+
if not root.exists():
73+
continue
74+
for pattern in ("*.so", "*.so.*"):
75+
binaries.update(path for path in root.rglob(pattern) if path.is_file())
76+
return sorted(binaries)
77+
78+
79+
def _scan_elf_baselines(readelf: str, binaries: Iterable[Path]) -> list[tuple[Path, list[str]]]:
80+
failures: list[tuple[Path, list[str]]] = []
81+
for binary in binaries:
82+
result = subprocess.run(
83+
[readelf, "-n", str(binary)],
84+
text=True,
85+
capture_output=True,
86+
check=False,
87+
timeout=20,
88+
)
89+
output = f"{result.stdout}\n{result.stderr}"
90+
blocked = _blocked_readelf_baselines(output)
91+
if blocked:
92+
failures.append((binary, blocked))
93+
return failures
94+
95+
96+
def _print_failures(failures: Iterable[tuple[Path, list[str]]]) -> None:
97+
print("ERROR: Linux package contains native binaries requiring newer x86_64 CPU baselines:", file=sys.stderr)
98+
for binary, blocked in failures:
99+
print(f" {binary}: {', '.join(blocked)}", file=sys.stderr)
100+
print("Use baseline-compatible wheels or pin the dependency before publishing the Linux package.", file=sys.stderr)
101+
102+
103+
def main(argv: list[str] | None = None) -> int:
104+
parser = argparse.ArgumentParser(description=__doc__)
105+
parser.add_argument(
106+
"--arch",
107+
default=None,
108+
help="Package architecture to validate. Defaults to platform.machine().",
109+
)
110+
parser.add_argument(
111+
"--skip-elf-scan",
112+
action="store_true",
113+
help="Skip readelf scanning and only run package-specific metadata checks.",
114+
)
115+
args = parser.parse_args(argv)
116+
117+
arch = _normalized_arch(args.arch)
118+
if arch != "x86_64":
119+
print(f"Linux native CPU baseline check skipped for arch={arch}")
120+
return 0
121+
122+
failures: list[tuple[Path, list[str]]] = []
123+
124+
try:
125+
numpy_baseline = _numpy_cpu_baseline()
126+
except RuntimeError as exc:
127+
print(f"ERROR: {exc}", file=sys.stderr)
128+
return 1
129+
130+
numpy_blocked = _blocked_x86_baselines(numpy_baseline)
131+
if numpy_blocked:
132+
failures.append((Path("numpy.__cpu_baseline__"), numpy_blocked))
133+
134+
if not args.skip_elf_scan:
135+
readelf = shutil.which("readelf")
136+
if not readelf:
137+
print("ERROR: readelf is required for Linux native baseline verification", file=sys.stderr)
138+
return 1
139+
failures.extend(_scan_elf_baselines(readelf, _native_binaries(_native_search_roots())))
140+
141+
if failures:
142+
_print_failures(failures)
143+
return 1
144+
145+
print(f"Linux native CPU baseline OK for x86_64 package; NumPy baseline={', '.join(numpy_baseline) or 'none'}")
146+
return 0
147+
148+
149+
if __name__ == "__main__":
150+
raise SystemExit(main())

tests/test_linux_support.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import launcher
99
import pytest
1010
import updater
11+
from scripts import check_linux_native_baseline
1112

1213

1314
def _linux_launcher_template() -> str:
@@ -123,6 +124,7 @@ def test_linux_tarball_installs_into_xdg_tree(monkeypatch, tmp_path):
123124

124125
def test_linux_build_script_declares_expected_package_contract():
125126
script = Path("installer/build_linux_app.sh").read_text(encoding="utf-8")
127+
requirements = Path("requirements.txt").read_text(encoding="utf-8")
126128

127129
assert "unknown-linux-gnu-install_only" in script
128130
assert 'PACKAGE_NAME="Thoth-${VERSION}-Linux-${PACKAGE_ARCH}"' in script
@@ -137,10 +139,41 @@ def test_linux_build_script_declares_expected_package_contract():
137139
assert "THOTH_SUPPRESS_INSTALL_PATH_HINT" in script
138140
assert "export PATH=\"$HOME/.local/bin:$PATH\"" in script
139141
assert "Run: $LAUNCH_CMD" in script
142+
assert 'numpy<2.3; python_version < "3.14"' in requirements
143+
assert "scripts/check_linux_native_baseline.py" in script
144+
assert "Checking native CPU baselines" in script
140145
for package in ("tools", "channels", "bundled_skills", "providers", "mcp_client", "migration"):
141146
assert package in script
142147

143148

149+
def test_linux_native_baseline_check_blocks_x86_v2_metadata():
150+
blocked = check_linux_native_baseline._blocked_x86_baselines(["SSE", "SSE2", "X86_V2"])
151+
152+
assert blocked == ["X86_V2"]
153+
154+
155+
def test_linux_native_baseline_check_allows_legacy_x86_metadata():
156+
blocked = check_linux_native_baseline._blocked_x86_baselines(["SSE", "SSE2"])
157+
158+
assert blocked == []
159+
160+
161+
def test_linux_native_baseline_check_blocks_readelf_x86_v3_output():
162+
output = "Properties: x86 ISA needed: x86-64-baseline, x86-64-v3"
163+
164+
blocked = check_linux_native_baseline._blocked_readelf_baselines(output)
165+
166+
assert blocked == ["X86_V3"]
167+
168+
169+
def test_linux_native_baseline_check_allows_readelf_baseline_output():
170+
output = "Properties: x86 ISA needed: x86-64-baseline"
171+
172+
blocked = check_linux_native_baseline._blocked_readelf_baselines(output)
173+
174+
assert blocked == []
175+
176+
144177
def test_linux_root_build_wrapper_delegates_to_installer_script():
145178
script = Path("build_linux_app.sh").read_text(encoding="utf-8")
146179

@@ -276,6 +309,7 @@ def test_release_workflows_reference_linux_artifact():
276309
assert "installer/install-linux.sh" in ci
277310
assert "Thoth-*-Linux-*.tar.gz" in release
278311
assert "libxcb-cursor0" in release
312+
assert "binutils" in release
279313
linux_smoke = release[release.index("Smoke Linux package"):]
280314
assert "--no-root-check" not in linux_smoke
281315
assert "HOME=\"$RUNNER_TEMP/thoth-linux-home\"" in linux_smoke

tests/test_startup_hardening.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,16 @@ def test_launcher_hints_for_linux_opencv_native_failure():
5656
assert any("libgl1" in hint for hint in hints)
5757

5858

59+
def test_launcher_hints_for_numpy_x86_v2_failure():
60+
hints = launcher._startup_failure_hints(
61+
"RuntimeError: NumPy was built with baseline optimizations: "
62+
"(X86_V2) but your machine doesn't support: (X86_V2)."
63+
)
64+
65+
assert any("NumPy/native wheel startup failure" in hint for hint in hints)
66+
assert any("x86-64-v2" in hint for hint in hints)
67+
68+
5969
def test_launcher_logs_app_tail_on_startup_failure(tmp_path, caplog):
6070
log_path = tmp_path / "thoth_app.log"
6171
log_path.write_text("line one\nTraceback\nImportError: libGL.so.1 missing\n", encoding="utf-8")

0 commit comments

Comments
 (0)