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:
Sergey
2026-08-26 22:53:29 -07:00
committed by GitHub
parent ffec466d74
commit f4e9fcb0ee
5 changed files with 537 additions and 67 deletions
+99 -62
View File
@@ -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
View File
@@ -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]
+1 -1
View File
@@ -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"
+326
View File
@@ -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
+93
View File
@@ -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()