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
+63 -5

No files matched your search

+15 -3
View File
@@ -147,8 +147,12 @@ class Setup:
atomic_json(self.state_path, state)
def detect_gpu(self):
found = json.loads(self.node('qmd-benchmark.mjs', '--detect', timeout=DEVICE_TIMEOUT) or '{}')
gpu = found.get('gpu') or False
try:
found = json.loads(self.node('qmd-benchmark.mjs', '--detect', timeout=DEVICE_TIMEOUT) or '{}')
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'
self.say(f"QMD devices: {f'{str(gpu).upper()}: {names}' if gpu else 'no GPU'}; CPU threads: {found.get('threads', '?')}")
return gpu
@@ -219,7 +223,15 @@ class Setup:
if self.dry_run:
self.say('DRYRUN benchmark QMD local search on GPU, then CPU')
return True
self.install(state)
try:
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 = []
devices = (['gpu'] if self.detect_gpu() else []) + ['cpu']
for device in devices:
+48 -2
View File
@@ -1,7 +1,10 @@
import importlib.util
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
@@ -13,16 +16,22 @@ spec.loader.exec_module(qmd)
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.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}
def __call__(self, arguments, env=None, timeout=None, check=True):
self.calls.append(list(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']:
return 'v22.4.0'
return self.node
if arguments[-1:] == ['set-models']:
return json.dumps({'previous': self.previous}) + chr(10)
if self.fail_pull and arguments[:2] == ['qmd', 'pull']:
@@ -47,6 +56,10 @@ class SetupTest(unittest.TestCase):
def setUp(self):
self.home = Path(tempfile.mkdtemp())
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)
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(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__':
unittest.main()