fix(qmd): treat Auto install and GPU probe failures as unsuitable

Auto now rolls back and reports "not suitable" when Node.js is too old,
npm or the model download fails. A failing GPU probe falls back to the
CPU in both Yes and Auto.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Julian lechnerandClaude Opus 5.5 committed 2026-10-08 12:40:12 +02:00
1 parent 60f739ceb9
commit f1d7894ba0
2 files changed
+60 -2

No files matched your search

+12
View File
@@ -147,8 +147,12 @@ class Setup:
atomic_json(self.state_path, state) atomic_json(self.state_path, state)
def detect_gpu(self): def detect_gpu(self):
try:
found = json.loads(self.node('qmd-benchmark.mjs', '--detect', timeout=DEVICE_TIMEOUT) or '{}') found = json.loads(self.node('qmd-benchmark.mjs', '--detect', timeout=DEVICE_TIMEOUT) or '{}')
gpu = found.get('gpu') or False gpu = found.get('gpu') or False
except Exception as error: # any probe failure means: use the CPU
self.say(f'QMD GPU detection failed, using the CPU: {str(error)[:200]}')
return False
names = ', '.join(found.get('devices') or []) or 'unnamed device' names = ', '.join(found.get('devices') or []) or 'unnamed device'
self.say(f"QMD devices: {f'{str(gpu).upper()}: {names}' if gpu else 'no GPU'}; CPU threads: {found.get('threads', '?')}") self.say(f"QMD devices: {f'{str(gpu).upper()}: {names}' if gpu else 'no GPU'}; CPU threads: {found.get('threads', '?')}")
return gpu return gpu
@@ -219,7 +223,15 @@ class Setup:
if self.dry_run: if self.dry_run:
self.say('DRYRUN benchmark QMD local search on GPU, then CPU') self.say('DRYRUN benchmark QMD local search on GPU, then CPU')
return True return True
try:
self.install(state) self.install(state)
except (ValueError, OSError, subprocess.SubprocessError, json.JSONDecodeError) as error:
print(f'QMD local search is not suitable for this computer: {error}')
try:
self.remove_all(state)
except (ValueError, OSError, subprocess.SubprocessError, json.JSONDecodeError) as cleanup:
print(f'QMD cleanup was incomplete: {cleanup}')
return False
results = [] results = []
devices = (['gpu'] if self.detect_gpu() else []) + ['cpu'] devices = (['gpu'] if self.detect_gpu() else []) + ['cpu']
for device in devices: for device in devices:
+48 -2
View File
@@ -1,7 +1,10 @@
import importlib.util import importlib.util
import json import json
import os
from pathlib import Path from pathlib import Path
import shutil import shutil
import subprocess
import sys
import tempfile import tempfile
import unittest import unittest
from unittest import mock from unittest import mock
@@ -13,16 +16,22 @@ spec.loader.exec_module(qmd)
class FakeRunner: class FakeRunner:
def __init__(self, installed=False, gpu='cuda', gpu_result=None, cpu_result=None, previous=None, fail_pull=False, list_output=None): def __init__(self, installed=False, gpu='cuda', gpu_result=None, cpu_result=None, previous=None, fail_pull=False, list_output=None,
node='v22.4.0', fail=(), on_call=None):
self.calls, self.installed, self.gpu = [], installed, gpu self.calls, self.installed, self.gpu = [], installed, gpu
self.previous, self.fail_pull, self.list_output = previous, fail_pull, list_output self.previous, self.fail_pull, self.list_output = previous, fail_pull, list_output
self.node, self.fail, self.on_call = node, fail, on_call
self.results = {'gpu': gpu_result, 'cpu': cpu_result} self.results = {'gpu': gpu_result, 'cpu': cpu_result}
def __call__(self, arguments, env=None, timeout=None, check=True): def __call__(self, arguments, env=None, timeout=None, check=True):
self.calls.append(list(arguments)) self.calls.append(list(arguments))
joined = ' '.join(arguments) joined = ' '.join(arguments)
if self.on_call:
self.on_call(arguments)
if any(part in joined for part in self.fail):
raise ValueError(f'{joined} failed')
if arguments[:2] == ['node', '--version']: if arguments[:2] == ['node', '--version']:
return 'v22.4.0' return self.node
if arguments[-1:] == ['set-models']: if arguments[-1:] == ['set-models']:
return json.dumps({'previous': self.previous}) + chr(10) return json.dumps({'previous': self.previous}) + chr(10)
if self.fail_pull and arguments[:2] == ['qmd', 'pull']: if self.fail_pull and arguments[:2] == ['qmd', 'pull']:
@@ -47,6 +56,10 @@ class SetupTest(unittest.TestCase):
def setUp(self): def setUp(self):
self.home = Path(tempfile.mkdtemp()) self.home = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.home, ignore_errors=True) self.addCleanup(shutil.rmtree, self.home, ignore_errors=True)
environment = mock.patch.dict(os.environ)
environment.start()
self.addCleanup(environment.stop)
os.environ.pop('XDG_CACHE_HOME', None)
(self.home / '.cache/qmd/models').mkdir(parents=True) (self.home / '.cache/qmd/models').mkdir(parents=True)
def setup(self, runner, dry_run=False): def setup(self, runner, dry_run=False):
@@ -221,6 +234,39 @@ class SetupTest(unittest.TestCase):
self.assertFalse((self.home / '.my-ai-configuration/qmd.json').exists()) self.assertFalse((self.home / '.my-ai-configuration/qmd.json').exists())
self.assertFalse(any('npm install' in ' '.join(c) for c in runner.calls)) self.assertFalse(any('npm install' in ' '.join(c) for c in runner.calls))
def scripts_dir(self):
return self.home / '.my-ai-configuration/qmd'
def test_benchmark_with_too_old_node_is_unsuitable(self):
runner = FakeRunner(node='v20.11.1', cpu_result={'device': 'cpu', 'passed': True})
self.assertFalse(self.setup(runner).benchmark())
self.assertFalse((self.home / '.my-ai-configuration/qmd.json').exists())
self.assertFalse(any('npm install' in ' '.join(c) for c in runner.calls))
def test_benchmark_npm_install_failure_is_unsuitable(self):
runner = FakeRunner(fail=('npm install',), cpu_result={'device': 'cpu', 'passed': True})
self.assertFalse(self.setup(runner).benchmark())
self.assertFalse((self.home / '.my-ai-configuration/qmd.json').exists())
self.assertFalse(any('--device' in c for c in runner.calls))
def test_benchmark_download_failure_cleans_up_what_it_created(self):
runner = FakeRunner(fail_pull=True, cpu_result={'device': 'cpu', 'passed': True})
self.assertFalse(self.setup(runner).benchmark())
self.assertTrue(any(c[-1:] == ['unset-models'] for c in runner.calls))
self.assertIn(['npm', 'uninstall', '--global', '@tobilu/qmd'], runner.calls)
self.assertFalse(self.scripts_dir().exists())
self.assertFalse((self.home / '.my-ai-configuration/qmd.json').exists())
def test_benchmark_detect_crash_runs_cpu_only(self):
runner = FakeRunner(fail=('--detect',), gpu_result={'device': 'gpu', 'passed': True}, cpu_result={'device': 'cpu', 'passed': True})
self.assertTrue(self.setup(runner).benchmark())
self.assertEqual(self.state()['device'], 'cpu')
self.assertFalse(any('--device' in c and 'gpu' in c for c in runner.calls))
def test_enable_with_detect_crash_uses_cpu(self):
self.setup(FakeRunner(fail=('--detect',))).enable(['claude'])
self.assertEqual(self.state()['device'], 'cpu')
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()