fix(installer): survive a release without a checksum manifest, and stop inventing GPUs (#736)
* installer: leave model downloads to first run when the release pins no checksums * installer: refuse an unpinned VOSK model before spending the download * installer: require a real device before reporting a Vulkan GPU * build: replace the deprecated license table and complete the packages list * installer: treat software Vulkan renderers as no GPU, not as one * installer: keep diagnostics off stdout so captured detection output stays clean
This commit is contained in:
+99
-62
@@ -26,12 +26,15 @@ print_success() {
|
||||
echo -e "\e[1;32m[SUCCESS]\e[0m $1"
|
||||
}
|
||||
|
||||
# Diagnostics go to stderr, not stdout: `exec > >(tee ...) 2>&1` keeps them in the
|
||||
# log and on screen either way, but stdout is what every $( ) captures. Writing
|
||||
# them there let the ERR trap's own message land inside a captured value.
|
||||
print_error() {
|
||||
echo -e "\e[1;31m[ERROR]\e[0m $1"
|
||||
echo -e "\e[1;31m[ERROR]\e[0m $1" >&2
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "\e[1;33m[WARNING]\e[0m $1"
|
||||
echo -e "\e[1;33m[WARNING]\e[0m $1" >&2
|
||||
}
|
||||
|
||||
command_exists() {
|
||||
@@ -272,6 +275,7 @@ GPU_NAME=""
|
||||
GPU_MEMORY=""
|
||||
HAS_VULKAN="no"
|
||||
VULKAN_DEVICE=""
|
||||
VULKAN_SOFTWARE_DEVICE=""
|
||||
# Initialize mode/state variables that are set later by flags or prompts so
|
||||
# that every read under `set -u` is well-defined in every code path.
|
||||
INSTALL_TAG=""
|
||||
@@ -865,25 +869,52 @@ get_cuda_cmake_args() {
|
||||
printf '%s\n' "$CUDA_ARGS"
|
||||
}
|
||||
|
||||
# Detect Vulkan support for whisper.cpp
|
||||
detect_vulkan() {
|
||||
# Check for vulkaninfo command
|
||||
if command -v vulkaninfo >/dev/null 2>&1; then
|
||||
# (|| true guards against pipefail aborts: head closes the pipe early
|
||||
# and grep exits 1 when there is no match)
|
||||
local vulkan_output
|
||||
vulkan_output=$(vulkaninfo --summary 2>/dev/null | head -20 || true)
|
||||
if [ -n "$vulkan_output" ]; then
|
||||
HAS_VULKAN="yes"
|
||||
# Try to extract GPU name
|
||||
VULKAN_DEVICE=$(echo "$vulkan_output" | grep -i "deviceName" | head -1 | cut -d'=' -f2 | xargs || true)
|
||||
if [ -z "$VULKAN_DEVICE" ]; then
|
||||
VULKAN_DEVICE="Vulkan-compatible GPU"
|
||||
fi
|
||||
# CPU implementations of Vulkan. whisper.cpp on these is slower than its own CPU
|
||||
# backend, so they must never be reported as a GPU. One list, read by both
|
||||
# detect_vulkan and check_vulkan_gpu_compatibility.
|
||||
VULKAN_SOFTWARE_PATTERNS=(llvmpipe swiftshader lavapipe zink virtio venus)
|
||||
|
||||
is_software_renderer() {
|
||||
local name="$1" pattern
|
||||
for pattern in "${VULKAN_SOFTWARE_PATTERNS[@]}"; do
|
||||
if printf '%s' "$name" | grep -iq "$pattern"; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# List the Vulkan devices vulkaninfo reports, one per line.
|
||||
vulkan_device_names() {
|
||||
command -v vulkaninfo >/dev/null 2>&1 || return 1
|
||||
# (|| true: no match must not abort pipefail.)
|
||||
vulkaninfo --summary 2>/dev/null | awk -F'=' '/deviceName/ {gsub(/^[ \t]+|[ \t]+$/, "", $2); if ($2 != "") print $2}' || true
|
||||
}
|
||||
|
||||
# Detect Vulkan support for whisper.cpp
|
||||
detect_vulkan() {
|
||||
# Only a hardware deviceName counts. The loader prints a header even when no
|
||||
# ICD resolves, and a software renderer is not a GPU -- reporting either as
|
||||
# one is what made Step 1 promise Vulkan performance the machine cannot give.
|
||||
HAS_VULKAN="no"
|
||||
VULKAN_DEVICE=""
|
||||
VULKAN_SOFTWARE_DEVICE=""
|
||||
|
||||
local devices name
|
||||
devices=$(vulkan_device_names) || return 1
|
||||
[ -n "$devices" ] || return 1
|
||||
|
||||
while IFS= read -r name; do
|
||||
[ -z "$name" ] && continue
|
||||
if is_software_renderer "$name"; then
|
||||
[ -n "$VULKAN_SOFTWARE_DEVICE" ] || VULKAN_SOFTWARE_DEVICE="$name"
|
||||
continue
|
||||
fi
|
||||
HAS_VULKAN="yes"
|
||||
VULKAN_DEVICE="$name"
|
||||
return 0
|
||||
done <<< "$devices"
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -912,17 +943,6 @@ check_vulkan_gpu_compatibility() {
|
||||
"SNB"
|
||||
)
|
||||
|
||||
# Software renderers and virtual devices to skip (not real hardware GPUs)
|
||||
# These are CPU-based implementations that shouldn't affect compatibility detection
|
||||
local SOFTWARE_RENDERER_PATTERNS=(
|
||||
"llvmpipe"
|
||||
"swiftshader"
|
||||
"lavapipe"
|
||||
"zink"
|
||||
"virtio"
|
||||
"venus"
|
||||
)
|
||||
|
||||
# Check if vulkaninfo is available
|
||||
if ! command -v vulkaninfo >/dev/null 2>&1; then
|
||||
echo "unknown:vulkaninfo not available"
|
||||
@@ -931,29 +951,19 @@ check_vulkan_gpu_compatibility() {
|
||||
|
||||
# Get all device names from vulkaninfo
|
||||
local DEVICE_NAMES_RAW
|
||||
DEVICE_NAMES_RAW=$(vulkaninfo --summary 2>/dev/null | awk -F'=' '/deviceName/ {gsub(/^[ \t]+|[ \t]+$/, "", $2); if ($2 != "") print $2}' || true)
|
||||
DEVICE_NAMES_RAW=$(vulkan_device_names || true)
|
||||
|
||||
# Separate hardware GPUs from software renderers
|
||||
local HARDWARE_GPUS=""
|
||||
local HARDWARE_GPU_COUNT=0
|
||||
while IFS= read -r device_name; do
|
||||
[ -z "$device_name" ] && continue
|
||||
|
||||
local is_software=false
|
||||
for pattern in "${SOFTWARE_RENDERER_PATTERNS[@]}"; do
|
||||
if echo "$device_name" | grep -iq "$pattern"; then
|
||||
is_software=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$is_software" = false ]; then
|
||||
if [ -n "$HARDWARE_GPUS" ]; then
|
||||
HARDWARE_GPUS="${HARDWARE_GPUS}, ${device_name}"
|
||||
else
|
||||
HARDWARE_GPUS="$device_name"
|
||||
fi
|
||||
((HARDWARE_GPU_COUNT++))
|
||||
if is_software_renderer "$device_name"; then
|
||||
continue
|
||||
fi
|
||||
if [ -n "$HARDWARE_GPUS" ]; then
|
||||
HARDWARE_GPUS="${HARDWARE_GPUS}, ${device_name}"
|
||||
else
|
||||
HARDWARE_GPUS="$device_name"
|
||||
fi
|
||||
done <<< "$DEVICE_NAMES_RAW"
|
||||
|
||||
@@ -966,7 +976,8 @@ check_vulkan_gpu_compatibility() {
|
||||
# Get Vulkan features and check for VK_KHR_16bit_storage
|
||||
# Modern GPUs (AMD, Intel Gen8+, NVIDIA) all support this extension
|
||||
local FEATURES_OUTPUT
|
||||
FEATURES_OUTPUT=$(vulkaninfo --features 2>/dev/null)
|
||||
# Exits non-zero on drivers that still print usable output; the text is what matters.
|
||||
FEATURES_OUTPUT=$(vulkaninfo --features 2>/dev/null) || true
|
||||
|
||||
if [ -n "$FEATURES_OUTPUT" ]; then
|
||||
# Check for VK_KHR_16bit_storage extension or equivalent features
|
||||
@@ -990,15 +1001,9 @@ check_vulkan_gpu_compatibility() {
|
||||
while IFS= read -r device_name; do
|
||||
[ -z "$device_name" ] && continue
|
||||
|
||||
# Skip software renderers
|
||||
local is_software=false
|
||||
for pattern in "${SOFTWARE_RENDERER_PATTERNS[@]}"; do
|
||||
if echo "$device_name" | grep -iq "$pattern"; then
|
||||
is_software=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
[ "$is_software" = true ] && continue
|
||||
if is_software_renderer "$device_name"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check against known incompatible patterns
|
||||
local is_incompatible=false
|
||||
@@ -1058,7 +1063,8 @@ detect_whispercpp_backends() {
|
||||
local VULKAN_COMPAT_REASON=""
|
||||
if [[ "$HAS_VULKAN" == "yes" && "$HAS_NVIDIA_GPU" != "yes" ]]; then
|
||||
local COMPAT_RESULT
|
||||
COMPAT_RESULT=$(check_vulkan_gpu_compatibility)
|
||||
# Returns non-zero for "unknown"/"incompatible", which are answers, not errors.
|
||||
COMPAT_RESULT=$(check_vulkan_gpu_compatibility) || true
|
||||
VULKAN_COMPATIBLE=$(echo "$COMPAT_RESULT" | cut -d':' -f1)
|
||||
VULKAN_COMPAT_REASON=$(echo "$COMPAT_RESULT" | cut -d':' -f2-)
|
||||
elif [[ "$HAS_NVIDIA_GPU" == "yes" ]]; then
|
||||
@@ -1125,6 +1131,8 @@ get_engine_recommendation() {
|
||||
elif [[ "$HAS_VULKAN" == "yes" ]]; then
|
||||
# Non-NVIDIA GPU with Vulkan support
|
||||
echo "whisper_cpp:✓:$VULKAN_DEVICE detected - Great performance with whisper.cpp Vulkan"
|
||||
elif [ -n "$VULKAN_SOFTWARE_DEVICE" ]; then
|
||||
echo "whisper_cpp:✓:Software Vulkan only ($VULKAN_SOFTWARE_DEVICE) - whisper.cpp CPU mode"
|
||||
elif [ "$TOTAL_RAM_GB" -ge 8 ]; then
|
||||
# No GPU but decent RAM
|
||||
echo "whisper_cpp:✓:No GPU detected, but ${TOTAL_RAM_GB}GB RAM - whisper.cpp CPU mode"
|
||||
@@ -3842,6 +3850,12 @@ whispercpp_pinned_revision() {
|
||||
awk '/^#[[:space:]]*whispercpp-revision:/ {print $3; exit}' "$MODEL_CHECKSUMS_FILE"
|
||||
}
|
||||
|
||||
# False when the cloned release predates the manifest. That app has no runtime
|
||||
# verification either, so refusing to pre-download would protect nothing.
|
||||
model_verification_available() {
|
||||
[ -f "$MODEL_CHECKSUMS_FILE" ]
|
||||
}
|
||||
|
||||
# Function to download and install Whisper tiny model
|
||||
install_whisper_model() {
|
||||
print_info "Installing Whisper tiny model (~75MB)..."
|
||||
@@ -3950,6 +3964,13 @@ install_vosk_models() {
|
||||
print_warning "The existing VOSK model carries no matching verification stamp; re-downloading it."
|
||||
fi
|
||||
|
||||
# Refuse before spending the download, not after verification rejects it.
|
||||
if [ -z "$EXPECTED_ZIP_DIGEST" ]; then
|
||||
print_error "No checksum is pinned for $SMALL_MODEL_ARCHIVE in $MODEL_CHECKSUMS_FILE."
|
||||
print_warning "VOSK model will be downloaded and verified on first application run."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check internet connectivity
|
||||
if ! command -v wget >/dev/null 2>&1 && ! command -v curl >/dev/null 2>&1; then
|
||||
print_warning "Neither wget nor curl found. Cannot download VOSK models."
|
||||
@@ -4308,15 +4329,27 @@ install_resources_to_venv || print_warning "Venv resource installation failed"
|
||||
# Install models based on selected engine
|
||||
# whisper.cpp is now the default engine
|
||||
if [ "$SKIP_MODELS" = "no" ]; then
|
||||
# Once, rather than once per engine.
|
||||
if ! model_verification_available; then
|
||||
print_warning "This release pins no model checksums, so the installer cannot verify"
|
||||
print_warning "model downloads. The application will download and verify them on"
|
||||
print_warning "first run instead."
|
||||
print_warning "(The installer is newer than ${INSTALL_TAG:-the checked-out revision}.)"
|
||||
fi
|
||||
|
||||
# Check which engines are installed and download appropriate models
|
||||
|
||||
# Install whisper.cpp model (default engine)
|
||||
if is_pywhispercpp_installed; then
|
||||
print_info "whisper.cpp is installed - downloading tiny model (default engine)..."
|
||||
install_whispercpp_model || print_warning "whisper.cpp model download failed - model will be downloaded on first run"
|
||||
if model_verification_available; then
|
||||
print_info "whisper.cpp is installed - downloading tiny model (default engine)..."
|
||||
install_whispercpp_model || print_warning "whisper.cpp model download failed - model will be downloaded on first run"
|
||||
else
|
||||
print_info "Leaving the whisper.cpp model to the first application run."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Install OpenAI Whisper model if whisper engine is installed
|
||||
# Needs no manifest: the sha256 is a path segment of its own URL.
|
||||
if "$VENV_DIR/bin/python" -c "import whisper" 2>/dev/null; then
|
||||
print_info "Whisper (OpenAI) is installed - downloading tiny model..."
|
||||
install_whisper_model || print_warning "Whisper model download failed - model will be downloaded on first run"
|
||||
@@ -4328,7 +4361,11 @@ fi
|
||||
|
||||
# Install VOSK models (always useful as fallback)
|
||||
if [ "$SKIP_MODELS" = "no" ]; then
|
||||
install_vosk_models || print_warning "VOSK model installation failed - models will be downloaded on first run"
|
||||
if model_verification_available; then
|
||||
install_vosk_models || print_warning "VOSK model installation failed - models will be downloaded on first run"
|
||||
else
|
||||
print_info "Leaving the VOSK model to the first application run."
|
||||
fi
|
||||
else
|
||||
print_info "Skipping VOSK model installation (--skip-models specified)"
|
||||
print_info "Models will be downloaded automatically on first application run"
|
||||
|
||||
+18
-4
@@ -1,5 +1,6 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61,<82"]
|
||||
# >=77 for the SPDX license string below.
|
||||
requires = ["setuptools>=77,<82"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
@@ -7,7 +8,8 @@ name = "vocalinux"
|
||||
dynamic = ["version"]
|
||||
description = "A seamless voice dictation system for Linux"
|
||||
readme = "README.md"
|
||||
license = {text = "AGPL-3.0"}
|
||||
license = "AGPL-3.0-only"
|
||||
license-files = ["LICENSE"]
|
||||
authors = [
|
||||
{name = "Jatin K Malik", email = "jatinkrmalik@gmail.com"}
|
||||
]
|
||||
@@ -23,7 +25,6 @@ classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Environment :: X11 Applications :: GTK",
|
||||
"Intended Audience :: End Users/Desktop",
|
||||
"License :: OSI Approved :: GNU Affero General Public License v3",
|
||||
"Operating System :: POSIX :: Linux",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
@@ -100,7 +101,20 @@ Changelog = "https://github.com/VocaHQ/vocalinux/releases"
|
||||
required-version = ">=0.12,<0.13"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["vocalinux", "vocalinux.resources", "vocalinux.speech_recognition", "vocalinux.text_injection", "vocalinux.ui", "vocalinux.ui.keyboard_backends", "vocalinux.utils"]
|
||||
# Resource dirs carry no .py files, but setuptools still treats them as packages.
|
||||
packages = [
|
||||
"vocalinux",
|
||||
"vocalinux.resources",
|
||||
"vocalinux.resources.icons",
|
||||
"vocalinux.resources.icons.scalable",
|
||||
"vocalinux.resources.sounds",
|
||||
"vocalinux.speech_recognition",
|
||||
"vocalinux.speech_recognition.data",
|
||||
"vocalinux.text_injection",
|
||||
"vocalinux.ui",
|
||||
"vocalinux.ui.keyboard_backends",
|
||||
"vocalinux.utils",
|
||||
]
|
||||
package-dir = {"" = "src"}
|
||||
|
||||
[tool.setuptools.dynamic]
|
||||
|
||||
@@ -6,7 +6,7 @@ __version__ = "0.16.0"
|
||||
__version_info__ = (0, 16, 0)
|
||||
__author__ = "Jatin K Malik"
|
||||
__email__ = "jatinkrmalik@gmail.com"
|
||||
__license__ = "AGPL-3.0"
|
||||
__license__ = "AGPL-3.0-only"
|
||||
__copyright__ = "Copyright 2023-2026 Jatin K Malik"
|
||||
__url__ = "https://github.com/VocaHQ/vocalinux"
|
||||
__description__ = "Free, offline voice dictation for Linux - speak and type anywhere"
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
"""Behavioural tests for the installer's Vulkan detection.
|
||||
|
||||
`detect_vulkan` used to accept any non-empty `vulkaninfo --summary` as a GPU, and
|
||||
read only the first 20 lines while `check_vulkan_gpu_compatibility` read all of
|
||||
them -- so the wizard could report a GPU in Step 1 and none in Step 3.
|
||||
"""
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
INSTALL_SH = Path(__file__).resolve().parents[1] / "install.sh"
|
||||
SOURCE = INSTALL_SH.read_text()
|
||||
|
||||
FUNCTIONS = (
|
||||
"is_software_renderer",
|
||||
"vulkan_device_names",
|
||||
"detect_vulkan",
|
||||
"check_vulkan_gpu_compatibility",
|
||||
)
|
||||
|
||||
PRELUDE = """
|
||||
set -uo pipefail
|
||||
HAS_VULKAN="no"
|
||||
VULKAN_DEVICE=""
|
||||
VULKAN_SOFTWARE_DEVICE=""
|
||||
print_info() { echo "INFO: $*"; }
|
||||
print_warning() { echo "WARNING: $*"; }
|
||||
"""
|
||||
|
||||
# The one list both detectors read; lifted from install.sh so it cannot drift.
|
||||
PATTERNS = re.search(r"^VULKAN_SOFTWARE_PATTERNS=\([^)]*\)", SOURCE, re.M)
|
||||
assert PATTERNS, "VULKAN_SOFTWARE_PATTERNS missing from install.sh"
|
||||
|
||||
# A loader that resolved no driver: header, error, no device.
|
||||
NO_DEVICES = """\
|
||||
ERROR: [Loader Message] Code 0 : setup_loader_term_phys_devs: Failed to detect any valid GPUs
|
||||
==========
|
||||
VULKANINFO
|
||||
==========
|
||||
|
||||
Vulkan Instance Version: 1.3.239
|
||||
|
||||
Instance Extensions: count = 19
|
||||
Layers: count = 0
|
||||
|
||||
Devices:
|
||||
========
|
||||
"""
|
||||
|
||||
# A real adapter listed past the old `head -20` cutoff.
|
||||
DEVICE_PAST_LINE_20 = (
|
||||
"".join(f"instance preamble line {n}\n" for n in range(1, 25))
|
||||
+ "\tdeviceName = AMD Radeon RX 7900 XTX (RADV NAVI31)\n"
|
||||
)
|
||||
|
||||
SOFTWARE_ONLY = """\
|
||||
==========
|
||||
VULKANINFO
|
||||
==========
|
||||
|
||||
Devices:
|
||||
========
|
||||
GPU0:
|
||||
\tdeviceName = llvmpipe (LLVM 15.0.6, 256 bits)
|
||||
"""
|
||||
|
||||
# A real adapter the loader happens to list after the software one.
|
||||
SOFTWARE_THEN_HARDWARE = (
|
||||
SOFTWARE_ONLY + "\tdeviceName = AMD Radeon RX 7900 XTX (RADV NAVI31)\n"
|
||||
)
|
||||
|
||||
DEVICE_EARLY = """\
|
||||
==========
|
||||
VULKANINFO
|
||||
==========
|
||||
|
||||
Devices:
|
||||
========
|
||||
GPU0:
|
||||
\tdeviceName = Intel(R) Iris(R) Xe Graphics (TGL GT2)
|
||||
"""
|
||||
|
||||
|
||||
def _functions() -> str:
|
||||
chunks = [PATTERNS.group(0)]
|
||||
for name in FUNCTIONS:
|
||||
match = re.search(rf"^{name}\(\) \{{.*?^\}}", SOURCE, re.M | re.S)
|
||||
assert match, f"{name} not found in install.sh"
|
||||
chunks.append(match.group(0))
|
||||
return "\n".join(chunks)
|
||||
|
||||
|
||||
# PATH is restricted to these so a real vulkaninfo cannot answer for a stub.
|
||||
NEEDED_TOOLS = ("awk", "head", "grep", "cut", "tr", "xargs", "sed", "cat")
|
||||
|
||||
|
||||
def _run(tmp_path: Path, summary: str | None, script: str) -> subprocess.CompletedProcess:
|
||||
"""Run `script` with a stubbed vulkaninfo, or with none at all."""
|
||||
binary = tmp_path / "bin"
|
||||
binary.mkdir(exist_ok=True)
|
||||
for tool in NEEDED_TOOLS:
|
||||
found = shutil.which(tool)
|
||||
if found and not (binary / tool).exists():
|
||||
(binary / tool).symlink_to(found)
|
||||
if summary is not None:
|
||||
stub = binary / "vulkaninfo"
|
||||
stub.write_text(textwrap.dedent("""\
|
||||
#!/bin/bash
|
||||
case "$1" in
|
||||
--summary) cat <<'SUMMARY'
|
||||
{summary}
|
||||
SUMMARY
|
||||
;;
|
||||
--features) echo "" ;;
|
||||
esac
|
||||
""").format(summary=summary.rstrip("\n")))
|
||||
stub.chmod(0o755)
|
||||
env_path = f'export PATH="{binary}"\n'
|
||||
return subprocess.run(
|
||||
["bash", "-c", PRELUDE + env_path + _functions() + "\n" + script],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
|
||||
class TestDetectVulkan:
|
||||
def test_a_loader_without_devices_is_not_a_gpu(self, tmp_path):
|
||||
result = _run(
|
||||
tmp_path, NO_DEVICES, 'detect_vulkan; echo "RC=$? HAS=$HAS_VULKAN DEV=[$VULKAN_DEVICE]"'
|
||||
)
|
||||
assert "RC=1 HAS=no DEV=[]" in result.stdout, result.stdout + result.stderr
|
||||
|
||||
def test_it_never_reports_the_fallback_string_as_a_device(self, tmp_path):
|
||||
"""The old code printed this literal as though it were an adapter name."""
|
||||
result = _run(tmp_path, NO_DEVICES, 'detect_vulkan; echo "DEV=[$VULKAN_DEVICE]"')
|
||||
assert "Vulkan-compatible GPU" not in result.stdout
|
||||
|
||||
def test_a_device_listed_past_line_20_is_still_found(self, tmp_path):
|
||||
result = _run(
|
||||
tmp_path,
|
||||
DEVICE_PAST_LINE_20,
|
||||
'detect_vulkan; echo "HAS=$HAS_VULKAN DEV=[$VULKAN_DEVICE]"',
|
||||
)
|
||||
assert "HAS=yes" in result.stdout, result.stdout + result.stderr
|
||||
assert "DEV=[AMD Radeon RX 7900 XTX (RADV NAVI31)]" in result.stdout
|
||||
|
||||
def test_an_ordinary_device_is_reported_by_name(self, tmp_path):
|
||||
result = _run(
|
||||
tmp_path, DEVICE_EARLY, 'detect_vulkan; echo "HAS=$HAS_VULKAN DEV=[$VULKAN_DEVICE]"'
|
||||
)
|
||||
assert "HAS=yes" in result.stdout
|
||||
assert "DEV=[Intel(R) Iris(R) Xe Graphics (TGL GT2)]" in result.stdout
|
||||
|
||||
def test_absent_vulkaninfo_is_no(self, tmp_path):
|
||||
result = _run(tmp_path, None, 'detect_vulkan; echo "RC=$? HAS=$HAS_VULKAN"')
|
||||
assert "RC=1 HAS=no" in result.stdout, result.stdout + result.stderr
|
||||
|
||||
|
||||
class TestTheTwoDetectorsAgree:
|
||||
"""Both readers must see the same devices, or the wizard contradicts itself."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"summary", [NO_DEVICES, DEVICE_PAST_LINE_20, DEVICE_EARLY], ids=["none", "late", "early"]
|
||||
)
|
||||
def test_a_claimed_gpu_is_a_gpu_the_compat_check_can_see(self, tmp_path, summary):
|
||||
result = _run(
|
||||
tmp_path,
|
||||
summary,
|
||||
'detect_vulkan || true; echo "HAS=$HAS_VULKAN"; '
|
||||
"check_vulkan_gpu_compatibility || true",
|
||||
)
|
||||
claimed = "HAS=yes" in result.stdout
|
||||
saw_no_devices = "No hardware GPU found" in result.stdout
|
||||
assert not (claimed and saw_no_devices), (
|
||||
"detect_vulkan announced a GPU that check_vulkan_gpu_compatibility "
|
||||
f"cannot find:\n{result.stdout}"
|
||||
)
|
||||
|
||||
|
||||
class TestSoftwareRenderers:
|
||||
"""whisper.cpp on a CPU implementation of Vulkan is slower than its own CPU
|
||||
backend, so a software renderer must not be reported as a GPU."""
|
||||
|
||||
def test_a_software_renderer_is_not_a_gpu(self, tmp_path):
|
||||
result = _run(
|
||||
tmp_path,
|
||||
SOFTWARE_ONLY,
|
||||
'detect_vulkan; echo "RC=$? HAS=$HAS_VULKAN DEV=[$VULKAN_DEVICE]"',
|
||||
)
|
||||
assert "RC=1 HAS=no DEV=[]" in result.stdout, result.stdout + result.stderr
|
||||
|
||||
def test_it_is_remembered_for_the_message(self, tmp_path):
|
||||
"""Step 1 says "software Vulkan only", which beats "no GPU detected"."""
|
||||
result = _run(
|
||||
tmp_path, SOFTWARE_ONLY, 'detect_vulkan || true; echo "SOFT=[$VULKAN_SOFTWARE_DEVICE]"'
|
||||
)
|
||||
assert "SOFT=[llvmpipe (LLVM 15.0.6, 256 bits)]" in result.stdout
|
||||
|
||||
def test_hardware_still_wins_when_listed_after_software(self, tmp_path):
|
||||
result = _run(
|
||||
tmp_path,
|
||||
SOFTWARE_THEN_HARDWARE,
|
||||
'detect_vulkan; echo "HAS=$HAS_VULKAN DEV=[$VULKAN_DEVICE]"',
|
||||
)
|
||||
assert "HAS=yes" in result.stdout, result.stdout + result.stderr
|
||||
assert "DEV=[AMD Radeon RX 7900 XTX (RADV NAVI31)]" in result.stdout
|
||||
|
||||
def test_both_detectors_agree_that_it_is_not_hardware(self, tmp_path):
|
||||
result = _run(
|
||||
tmp_path,
|
||||
SOFTWARE_ONLY,
|
||||
'detect_vulkan || true; echo "HAS=$HAS_VULKAN"; check_vulkan_gpu_compatibility || true',
|
||||
)
|
||||
assert "HAS=no" in result.stdout
|
||||
assert "No hardware GPU found" in result.stdout
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name", ["llvmpipe", "swiftshader", "lavapipe", "zink", "virtio", "venus"]
|
||||
)
|
||||
def test_every_pattern_is_recognised(self, tmp_path, name):
|
||||
result = _run(
|
||||
tmp_path, NO_DEVICES, f'is_software_renderer "{name} (something)"; echo "RC=$?"'
|
||||
)
|
||||
assert "RC=0" in result.stdout, result.stdout + result.stderr
|
||||
|
||||
|
||||
class TestDiagnosticsDoNotPolluteCapturedOutput:
|
||||
"""install.sh runs under `set -Eeuo pipefail` with an ERR trap that calls
|
||||
print_error. While print_error wrote to stdout, that message landed *inside*
|
||||
`$(detect_whispercpp_backends)` -- so a machine with a working Vulkan GPU
|
||||
parsed garbage, fell back to CPU, and never logged the error, because the
|
||||
substitution had swallowed it. `vulkaninfo --features` exits non-zero on real
|
||||
drivers, which is what fired the trap.
|
||||
"""
|
||||
|
||||
# A working hardware GPU, and --features exiting non-zero as it does in practice.
|
||||
STUB = """#!/bin/bash
|
||||
case "$1" in
|
||||
--summary) echo "\tdeviceName = Intel(R) Graphics (LNL)" ;;
|
||||
--features) echo "VK_KHR_16bit_storage"; exit 1 ;;
|
||||
esac
|
||||
"""
|
||||
|
||||
FUNCTIONS = (
|
||||
"is_software_renderer",
|
||||
"vulkan_device_names",
|
||||
"detect_nvidia_gpu",
|
||||
"detect_vulkan",
|
||||
"check_vulkan_gpu_compatibility",
|
||||
"cuda_toolkit_root_has_runtime_library",
|
||||
"find_valid_cuda_toolkit_root",
|
||||
"detect_whispercpp_backends",
|
||||
# the real ones, so the test fails if they go back to stdout
|
||||
"print_error",
|
||||
"print_warning",
|
||||
)
|
||||
|
||||
def _script(self, tmp_path: Path, body: str) -> subprocess.CompletedProcess:
|
||||
binary = tmp_path / "bin"
|
||||
binary.mkdir(exist_ok=True)
|
||||
for tool in NEEDED_TOOLS + ("find", "ls", "wc", "dirname", "basename", "readlink"):
|
||||
found = shutil.which(tool)
|
||||
if found and not (binary / tool).exists():
|
||||
(binary / tool).symlink_to(found)
|
||||
stub = binary / "vulkaninfo"
|
||||
stub.write_text(self.STUB)
|
||||
stub.chmod(0o755)
|
||||
|
||||
chunks = [PATTERNS.group(0)]
|
||||
for name in self.FUNCTIONS:
|
||||
match = re.search(rf"^{name}\(\) \{{.*?^\}}", SOURCE, re.M | re.S)
|
||||
assert match, f"{name} not found in install.sh"
|
||||
chunks.append(match.group(0))
|
||||
|
||||
prelude = (
|
||||
"set -Eeuo pipefail\n"
|
||||
f'export PATH="{binary}"\n'
|
||||
'HAS_NVIDIA_GPU="no"\nHAS_VULKAN="no"\nVULKAN_DEVICE=""\n'
|
||||
'VULKAN_SOFTWARE_DEVICE=""\nGPU_NAME=""\nGPU_MEMORY=""\n'
|
||||
'INSTALL_LOG_FILE="/dev/null"\n'
|
||||
'print_info() { echo "INFO: $*"; }\n'
|
||||
'print_success() { echo "SUCCESS: $*"; }\n'
|
||||
)
|
||||
trap = (
|
||||
"trap 'print_error \"Unexpected error near line $LINENO "
|
||||
"(exit code $?); see $INSTALL_LOG_FILE\"' ERR\n"
|
||||
)
|
||||
return subprocess.run(
|
||||
["bash", "-c", prelude + "\n".join(chunks) + "\n" + trap + body],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
def test_print_error_stays_off_stdout(self, tmp_path):
|
||||
result = self._script(tmp_path, 'print_error "boom"')
|
||||
assert "boom" not in result.stdout
|
||||
assert "boom" in result.stderr
|
||||
|
||||
def test_print_warning_stays_off_stdout(self, tmp_path):
|
||||
result = self._script(tmp_path, 'print_warning "careful"')
|
||||
assert "careful" not in result.stdout
|
||||
assert "careful" in result.stderr
|
||||
|
||||
def test_backend_info_is_a_single_clean_line(self, tmp_path):
|
||||
result = self._script(
|
||||
tmp_path, 'B=$(detect_whispercpp_backends); printf "%s" "$B" | wc -l; echo "[$B]"'
|
||||
)
|
||||
assert "ERROR" not in result.stdout, f"trap output captured:\n{result.stdout}"
|
||||
lines = result.stdout.splitlines()
|
||||
assert lines, result.stdout + result.stderr
|
||||
assert lines[0] == "0", f"BACKEND_INFO spans several lines:\n{result.stdout}"
|
||||
|
||||
def test_a_working_gpu_is_still_offered(self, tmp_path):
|
||||
"""The user-visible symptom: Step 3 refused GPU on hardware that supports it."""
|
||||
result = self._script(
|
||||
tmp_path,
|
||||
'B=$(detect_whispercpp_backends); CAN=$(echo "$B" | cut -d: -f3); echo "CAN=[$CAN]"',
|
||||
)
|
||||
assert "CAN=[true]" in result.stdout, result.stdout + result.stderr
|
||||
@@ -571,5 +571,98 @@ download_model_file() {
|
||||
self.assertEqual(leftovers, [])
|
||||
|
||||
|
||||
class TestReleaseWithoutAManifest(unittest.TestCase):
|
||||
"""install.sh comes from main; the tree it clones is the last release tag.
|
||||
|
||||
An absent manifest means the release predates pinning, so models are left to
|
||||
first run. A manifest that omits a model still fails closed.
|
||||
"""
|
||||
|
||||
SOURCE = INSTALL_SH.read_text()
|
||||
|
||||
PRELUDE = """
|
||||
set -uo pipefail
|
||||
print_info() { echo "INFO: $*"; }
|
||||
print_warning() { echo "WARNING: $*"; }
|
||||
print_error() { echo "ERROR: $*"; }
|
||||
print_success() { echo "SUCCESS: $*"; }
|
||||
command_exists() { command -v "$1" >/dev/null 2>&1; }
|
||||
check_connectivity() { return 0; }
|
||||
download_model_file() { echo "DOWNLOAD ATTEMPTED"; return 1; }
|
||||
"""
|
||||
|
||||
def _function_body(self, name: str) -> str:
|
||||
start = self.SOURCE.index(f"\n{name}() {{")
|
||||
end = self.SOURCE.index("\n}\n", start) + len("\n}\n")
|
||||
return self.SOURCE[start:end]
|
||||
|
||||
def _run(self, script: str, functions=()) -> subprocess.CompletedProcess:
|
||||
body = self.PRELUDE + "\n".join(self._function_body(n) for n in functions)
|
||||
return subprocess.run(["bash", "-c", body + "\n" + script], capture_output=True, text=True)
|
||||
|
||||
def test_available_when_the_manifest_ships(self):
|
||||
result = self._run(
|
||||
f'MODEL_CHECKSUMS_FILE="{REPO_ROOT}/src/vocalinux/utils/model_checksums.txt"; '
|
||||
"model_verification_available",
|
||||
functions=("model_verification_available",),
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def test_unavailable_when_the_release_predates_pinning(self):
|
||||
with TemporaryDirectory() as tmp:
|
||||
result = self._run(
|
||||
f'MODEL_CHECKSUMS_FILE="{tmp}/nope.txt"; model_verification_available',
|
||||
functions=("model_verification_available",),
|
||||
)
|
||||
self.assertEqual(result.returncode, 1)
|
||||
|
||||
def test_vosk_refuses_before_spending_the_download(self):
|
||||
"""The 40MB fetch must not happen when nothing can verify it."""
|
||||
with TemporaryDirectory() as tmp:
|
||||
result = self._run(
|
||||
f'DATA_DIR="{tmp}/data"; VOCALINUX_TMP_DIR="{tmp}/tmp"; '
|
||||
f'mkdir -p "$DATA_DIR" "$VOCALINUX_TMP_DIR"; '
|
||||
f'MODEL_CHECKSUMS_FILE="{tmp}/nope.txt"; '
|
||||
"install_vosk_models; echo RC=$?",
|
||||
functions=("pinned_digest_for", "install_vosk_models"),
|
||||
)
|
||||
out = result.stdout + result.stderr
|
||||
self.assertIn("RC=1", out)
|
||||
self.assertIn("No checksum is pinned", out)
|
||||
self.assertNotIn("DOWNLOAD ATTEMPTED", out, "refused only after downloading 40MB")
|
||||
|
||||
def _model_install_block(self) -> str:
|
||||
"""The top-level section deciding which models are pre-downloaded."""
|
||||
start = self.SOURCE.index("# Install models based on selected engine")
|
||||
end = self.SOURCE.index("# config.json survives reinstalls", start)
|
||||
return self.SOURCE[start:end]
|
||||
|
||||
def test_the_manifest_dependent_downloads_are_gated(self):
|
||||
"""Both engines whose digests live in the manifest sit behind the gate."""
|
||||
block = self._model_install_block()
|
||||
for call in ("install_whispercpp_model ||", "install_vosk_models ||"):
|
||||
gate = block.rindex("if model_verification_available; then", 0, block.index(call))
|
||||
self.assertLess(gate, block.index(call), f"{call} is not behind the gate")
|
||||
|
||||
def test_openai_whisper_stays_ungated(self):
|
||||
"""It verifies against the sha256 in its own URL, so needs no manifest."""
|
||||
block = self._model_install_block()
|
||||
call = block.index("install_whisper_model ||")
|
||||
preceding = block[:call]
|
||||
self.assertGreater(
|
||||
preceding.rindex("fi"),
|
||||
preceding.rindex("if model_verification_available; then"),
|
||||
"the OpenAI Whisper download must not be behind the manifest gate",
|
||||
)
|
||||
|
||||
def test_the_skew_is_explained_once(self):
|
||||
block = self._model_install_block()
|
||||
self.assertEqual(
|
||||
block.count("if ! model_verification_available; then"),
|
||||
1,
|
||||
"the reason models are skipped should be stated once, not per engine",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user