diff --git a/starpilot/assets/model_manager.py b/starpilot/assets/model_manager.py index f174af12c0..32d1ae5307 100644 --- a/starpilot/assets/model_manager.py +++ b/starpilot/assets/model_manager.py @@ -937,6 +937,7 @@ class ModelManager: try: self._download_model(model_to_download, allow_gpu_without_gpu) finally: + self.downloading_model = False self.params_memory.remove(ALLOW_GPU_DOWNLOAD_WITHOUT_GPU_PARAM) def _download_artifact_to_path(self, model_key: str, file_path: Path, remote_filename: str, @@ -1057,8 +1058,8 @@ class ModelManager: self.params_memory.put(DOWNLOAD_PROGRESS_PARAM, "eGPU variant downloaded!") return True finally: - self.params_memory.remove(MODEL_LAB_DOWNLOAD_PARAM) self.downloading_model = False + self.params_memory.remove(MODEL_LAB_DOWNLOAD_PARAM) def _download_model(self, model_to_download: str, allow_gpu_without_gpu: bool): self.downloading_model = True @@ -1129,6 +1130,7 @@ class ModelManager: try: self._download_all_models(allow_gpu_without_gpu) finally: + self.downloading_model = False self.params_memory.remove(ALLOW_GPU_DOWNLOAD_WITHOUT_GPU_PARAM) def _download_all_models(self, allow_gpu_without_gpu: bool): diff --git a/starpilot/assets/model_sizes.py b/starpilot/assets/model_sizes.py new file mode 100644 index 0000000000..d7595fc73d --- /dev/null +++ b/starpilot/assets/model_sizes.py @@ -0,0 +1,81 @@ +"""Local logical payload sizes. No HEAD requests, hashing, or model mutation.""" +from pathlib import Path +import re +import threading +import time + +MAX_DECLARED_BYTES = 1 << 50 + + +def positive_size(value): + return value if type(value) is int and 0 < value <= MAX_DECLARED_BYTES else None + + +def artifact_size(path, declared=None): + path = Path(path) + declared = positive_size(declared) + result = {'fileSizeBytes': None, 'declaredSizeBytes': declared, 'downloadedBytes': None, + 'sizeSource': 'metadata' if declared else 'unknown', + 'sizeStatus': 'declared' if declared else 'unknown'} + try: + if path.is_file(): + size = path.stat().st_size + result.update(fileSizeBytes=size if size > 0 else None, downloadedBytes=size, + sizeSource='installed', sizeStatus='complete' if size > 0 else 'partial') + else: + manifest = Path(str(path) + '.chunkmanifest') + chunks = [p for p in path.parent.glob(path.name + '.chunk*') + if re.fullmatch(re.escape(path.name) + r'\.chunk\d+of\d+', p.name) and p.is_file()] + if not manifest.is_file() and not chunks: + return result + count = None + if manifest.is_file(): + try: + text = manifest.read_text().strip() + count = int(text) if text.isdecimal() and len(text) <= 5 else None + except (ValueError, UnicodeError): + pass + expected = [Path(f'{path}.chunk{i + 1:02d}of{count:02d}') for i in range(count)] if count and count <= 10000 else [] + complete = bool(expected) and set(chunks) == set(expected) + downloaded = sum(p.stat().st_size for p in chunks) + result.update(downloadedBytes=downloaded, fileSizeBytes=downloaded if complete and downloaded > 0 else None, + sizeSource='installed' if complete else 'partial', + sizeStatus='complete' if complete and downloaded > 0 else 'partial') + if result['fileSizeBytes'] is not None and declared and declared != result['fileSizeBytes']: + result['sizeStatus'] = 'mismatch' + except OSError: + result.update(fileSizeBytes=None, downloadedBytes=None, sizeSource='unknown', sizeStatus='unknown') + return result + + +class ModelSizes: + """Bounded cache for local catalogue size reads, independent of statistics.""" + def __init__(self): + self.lock = threading.Lock() + self.sizes = {} + + def size(self, path, declared): + # Short TTL avoids repeated directory scans on status polls, not stale sizes + # across completed downloads. Bad metadata is treated as unknown by helper. + key = (str(path), repr(declared)) + with self.lock: + cached = self.sizes.get(key) + if cached is None or time.monotonic() >= cached[0]: + cached = (time.monotonic() + 2, artifact_size(path, declared)) + if len(self.sizes) > 1000: + self.sizes.clear() + self.sizes[key] = cached + return dict(cached[1]) + + def annotate(self, models, models_path, builtin_path, metadata, accelerator_filename): + for model in models: + key = model['value'] + entry = metadata.get(key, {}) + entry = entry if isinstance(entry, dict) else {} + path = builtin_path if model['builtin'] else Path(models_path) / f'{key}_driving_tinygrad.pkl' + model.update(self.size(path, entry.get('artifact_size'))) + variants = entry.get('accelerator_artifacts', {}) + variant = variants.get('chestnut') if isinstance(variants, dict) else None + if isinstance(variant, dict): + model['modelLabFileSize'] = self.size(Path(models_path) / accelerator_filename(key), variant.get('artifact_size')) + return models diff --git a/starpilot/assets/tests/test_download_failure_recovery.py b/starpilot/assets/tests/test_download_failure_recovery.py new file mode 100644 index 0000000000..ee12b69586 --- /dev/null +++ b/starpilot/assets/tests/test_download_failure_recovery.py @@ -0,0 +1,53 @@ +"""Source-class fault seam; native imports and real Params are not exercised.""" +import ast +from pathlib import Path +import pytest + + +def manager_class(): + tree = ast.parse((Path(__file__).parents[1] / 'model_manager.py').read_text()) + names = {'MODEL_LAB_ACCELERATOR', 'ALLOW_GPU_DOWNLOAD_WITHOUT_GPU_PARAM', + 'DOWNLOAD_PROGRESS_PARAM', 'MODEL_DOWNLOAD_PARAM', 'MODEL_LAB_DOWNLOAD_PARAM', + 'DEFAULT_MODEL_KEY', 'MODEL_KEY_CANONICAL_MAP'} + nodes = [n for n in tree.body if + isinstance(n, ast.Assign) and any(isinstance(t, ast.Name) and t.id in names for t in n.targets) or + isinstance(n, ast.FunctionDef) and n.name in {'canonical_model_key', 'is_builtin_model_key'} or + isinstance(n, ast.ClassDef) and n.name == 'ModelManager'] + ns = {'Path': Path, 'model_accelerator_artifact_metadata': lambda *args: {}} + exec(compile(ast.Module(body=nodes, type_ignores=[]), 'model_manager.py', 'exec'), ns) + return ns['ModelManager'] + + +class FaultParams: + def __init__(self, cleanup_fault=False): + self.cleanup_fault = cleanup_fault + def get_bool(self, key): + return False + def put(self, *args): + raise OSError('synthetic Params write failure') + def remove(self, *args): + if self.cleanup_fault: + raise OSError('synthetic Params removal failure') + + +@pytest.mark.parametrize('cleanup_fault', [False, True]) +@pytest.mark.parametrize('operation', ['single', 'all', 'accelerator']) +def test_failed_download_releases_refresh_gate(cleanup_fault, operation): + cls = manager_class() + manager = cls.__new__(cls) + manager.downloading_model = False + manager.params_memory = FaultParams(cleanup_fault) + if operation == 'all': + manager._download_all_models = lambda allow: manager._download_model('rdf43', allow) + call = manager.download_all_models + elif operation == 'accelerator': + # Failure at upstream artifact metadata lookup; cleanup must release the gate. + def fail(): + raise OSError('synthetic hardware lookup failure') + cls.download_model_accelerator.__globals__['model_accelerator_artifact_metadata'] = lambda *args: fail() + call = lambda: manager.download_model_accelerator('gpu') + else: + call = lambda: manager.download_model('rdf43') + with pytest.raises(OSError): + call() + assert manager.downloading_model is False diff --git a/starpilot/assets/tests/test_model_sizes.py b/starpilot/assets/tests/test_model_sizes.py new file mode 100644 index 0000000000..e3bbc8ec79 --- /dev/null +++ b/starpilot/assets/tests/test_model_sizes.py @@ -0,0 +1,99 @@ +from pathlib import Path + +import pytest + +from openpilot.starpilot.assets.model_sizes import artifact_size, positive_size + + +@pytest.mark.parametrize('value', [None, True, False, 0, -1, '123', 'abc', 1.2, 2**60]) +def test_invalid_declared_size(value): + assert positive_size(value) is None + + +def test_monolithic_actual_size_precedes_chunks_and_reports_mismatch(tmp_path): + path = tmp_path / 'model.pkl' + path.write_bytes(b'actual model') + Path(str(path) + '.chunkmanifest').write_text('1') + Path(str(path) + '.chunk01of01').write_bytes(b'old chunk') + result = artifact_size(path, 2) + assert result['fileSizeBytes'] == len(b'actual model') + assert result['downloadedBytes'] == len(b'actual model') + assert result['sizeStatus'] == 'mismatch' + + +def test_complete_chunks_exclude_manifest_and_missing_chunk_is_partial(tmp_path): + path = tmp_path / 'model.pkl' + Path(str(path) + '.chunkmanifest').write_text('2') + first, second = Path(str(path) + '.chunk01of02'), Path(str(path) + '.chunk02of02') + first.write_bytes(b'abc') + second.write_bytes(b'defg') + assert artifact_size(path, 7)['fileSizeBytes'] == 7 + assert artifact_size(path, 7)['sizeStatus'] == 'complete' + second.unlink() + result = artifact_size(path, 7) + assert result['sizeStatus'] == 'partial' + assert result['fileSizeBytes'] is None + assert result['downloadedBytes'] == 3 and result['declaredSizeBytes'] == 7 + + +@pytest.mark.parametrize('manifest', ['0', '-1', 'bad', '100000000000000', '', '2.0']) +def test_malformed_chunk_manifest_is_not_complete(tmp_path, manifest): + path = tmp_path / 'model.pkl' + Path(str(path) + '.chunkmanifest').write_text(manifest) + assert artifact_size(path)['fileSizeBytes'] is None + assert artifact_size(path)['sizeStatus'] == 'partial' + + +def test_missing_manifest_and_missing_file(tmp_path): + path = tmp_path / 'model.pkl' + assert artifact_size(path, 20)['sizeStatus'] == 'declared' + assert artifact_size(path)['sizeStatus'] == 'unknown' + Path(str(path) + '.chunk01of02').write_bytes(b'partial') + assert artifact_size(path)['sizeStatus'] == 'partial' + assert artifact_size(path)['downloadedBytes'] == 7 + + +def test_stat_failure_is_unknown(tmp_path, monkeypatch): + path = tmp_path / 'model.pkl' + path.write_bytes(b'x') + def failed(*args, **kwargs): + raise PermissionError('test denied') + monkeypatch.setattr(Path, 'stat', failed) + assert artifact_size(path, 10)['sizeStatus'] == 'unknown' + + +def test_zero_byte_artifact_is_not_valid_size(tmp_path): + path = tmp_path / 'model.pkl' + path.touch() + assert artifact_size(path)['fileSizeBytes'] is None + assert artifact_size(path)['sizeStatus'] == 'partial' + + +def test_catalog_annotation_is_independent_of_statistics(tmp_path): + from openpilot.starpilot.assets.model_sizes import ModelSizes + (tmp_path / 'builtin.pkl').write_bytes(b'123') + (tmp_path / 'gpu_driving_tinygrad.pkl').write_bytes(b'12345') + (tmp_path / 'gpu_accel.pkl').write_bytes(b'1234567') + models = [{'value': 'stock', 'builtin': True}, {'value': 'gpu', 'builtin': False}] + result = ModelSizes().annotate(models, tmp_path, tmp_path / 'builtin.pkl', + {'gpu': {'artifact_size': 5, 'accelerator_artifacts': {'chestnut': {'artifact_size': 7}}}}, lambda key: key + '_accel.pkl') + assert result[0]['fileSizeBytes'] == 3 + assert result[1]['fileSizeBytes'] == 5 + assert result[1]['modelLabFileSize']['fileSizeBytes'] == 7 + assert all('stats' not in model for model in result) + + +def test_size_cache_refresh_and_bound(tmp_path, monkeypatch): + from openpilot.starpilot.assets import model_sizes + now = [0] + monkeypatch.setattr(model_sizes.time, 'monotonic', lambda: now[0]) + service = model_sizes.ModelSizes() + path = tmp_path / 'model.pkl' + assert service.size(path, None)['fileSizeBytes'] is None + path.write_bytes(b'123') + assert service.size(path, None)['fileSizeBytes'] is None + now[0] = 3 + assert service.size(path, None)['fileSizeBytes'] == 3 + for index in range(1002): + service.size(tmp_path / str(index), None) + assert len(service.sizes) <= 1001 diff --git a/starpilot/system/the_galaxy/assets/components/tools/model_hardware.js b/starpilot/system/the_galaxy/assets/components/tools/model_hardware.js new file mode 100644 index 0000000000..e10131aa01 --- /dev/null +++ b/starpilot/system/the_galaxy/assets/components/tools/model_hardware.js @@ -0,0 +1,52 @@ +// Presentation only. No Params writes, telemetry collection or inferred mileage. +const FILTER_KEY = "galaxy.modelManager.hardwareFilter"; +const FILTERS = new Set(["both", "gpu", "comma"]); + +export function readHardwareFilter() { + try { + const value = localStorage.getItem(FILTER_KEY); + return FILTERS.has(value) ? value : "both"; + } catch { + return "both"; + } +} + +export function saveHardwareFilter(value) { + const filter = FILTERS.has(value) ? value : "both"; + try { localStorage.setItem(FILTER_KEY, filter); } catch { /* View still works without storage. */ } + return filter; +} + +export function matchesHardware(model, filter) { + if (filter === "gpu") return model?.requiresGpu === true; + if (filter === "comma") return model?.requiresGpu === false; + return true; +} + +export function hardwareLabel(model) { + if (model?.requiresGpu === true) return "GPU · External"; + if (model?.requiresGpu === false) return "Comma · On-device"; + return "Hardware unknown"; +} + +function bytes(value) { + return Number.isSafeInteger(value) && value > 0 ? value : null; +} + +function formatBytes(value) { + return value >= 1e9 ? `${(value / 1e9).toFixed(2)} GB` : `${(value / 1e6).toFixed(1)} MB`; +} + +export function fileSizeText(model) { + // modelSize is an architecture class, never a byte count. + const installed = bytes(model?.fileSizeBytes); + const declared = bytes(model?.declaredSizeBytes); + const downloaded = bytes(model?.downloadedBytes); + if (model?.partial || model?.sizeStatus === "partial") { + return `Partial: ${downloaded ? formatBytes(downloaded) : "unknown"} / ${declared ? formatBytes(declared) : "unknown"}`; + } + if (installed && declared && installed !== declared) return `${formatBytes(installed)} · size mismatch`; + if (installed) return formatBytes(installed); + if (declared) return `${formatBytes(declared)} · declared`; + return "Unavailable"; +} diff --git a/starpilot/system/the_galaxy/assets/components/tools/model_manager.js b/starpilot/system/the_galaxy/assets/components/tools/model_manager.js index 690110771a..683b60c671 100644 --- a/starpilot/system/the_galaxy/assets/components/tools/model_manager.js +++ b/starpilot/system/the_galaxy/assets/components/tools/model_manager.js @@ -5,6 +5,7 @@ const state = reactive({ refreshing: false, error: "", actionBusy: false, + selectionUncertain: true, sortMode: "release_date", communityFavoriteFilter: "all", userFavoriteFilter: "all", @@ -27,7 +28,10 @@ const state = reactive({ let initialized = false; let pollingHandle = null; -let statusInFlight = false; +let statusInFlight = null; +let statusGeneration = 0; +let viewGeneration = 0; +let selectionWrite = null; let lastStatusSignature = ""; const REQUEST_TIMEOUT_MS = 20000; @@ -216,11 +220,16 @@ async function fetchJson(url, options = {}) { } async function fetchStatus() { - if (statusInFlight) return; - statusInFlight = true; + const generation = statusGeneration; + if (statusInFlight === generation) return; + statusInFlight = generation; try { + // Remount readback must follow settlement of an already sent selection. + if (selectionWrite) await selectionWrite.catch(() => {}); + if (generation !== statusGeneration || !isModelRouteActive()) return; const payload = await fetchJson("/api/models/status"); + if (generation !== statusGeneration || !isModelRouteActive()) return; const models = Array.isArray(payload.models) ? payload.models.filter(model => model && typeof model === "object") @@ -249,6 +258,15 @@ async function fetchStatus() { }; state.error = ""; + state.selectionUncertain = false; + // selected attributes cannot reset a select's dirty native value after a user edit. + queueMicrotask(() => { + if (generation !== statusGeneration || !isModelRouteActive()) return; + for (const profile of ["small", "big"]) { + const select = document.getElementById(`mm-active-${profile}-model-select`); + if (select) select.value = profile === "big" ? state.activeBigModel : state.activeSmallModel; + } + }); const signature = [ state.models.length, @@ -271,12 +289,15 @@ async function fetchStatus() { }); } } catch (error) { + if (generation !== statusGeneration || !isModelRouteActive()) return; state.error = error?.message || String(error); logDebug("Status fetch failed", state.error); } finally { - statusInFlight = false; - state.loading = false; - state.refreshing = false; + if (statusInFlight === generation) statusInFlight = null; + if (generation === statusGeneration && isModelRouteActive()) { + state.loading = false; + state.refreshing = false; + } } } @@ -296,8 +317,9 @@ async function refreshAll(showToast = false) { function ensurePolling() { if (pollingHandle) return; + const generation = viewGeneration; const poll = async () => { - if (!isModelRouteActive()) { + if (generation !== viewGeneration || !isModelRouteActive()) { pollingHandle = null; return; } @@ -307,7 +329,7 @@ function ensurePolling() { await fetchStatus(); nextDelay = state.status.downloading ? ACTIVE_POLL_INTERVAL_MS : IDLE_POLL_INTERVAL_MS; } finally { - pollingHandle = setTimeout(poll, nextDelay); + if (generation === viewGeneration) pollingHandle = setTimeout(poll, nextDelay); } }; @@ -317,13 +339,13 @@ function ensurePolling() { async function setActiveModel(modelKey, profile = "") { const model = state.models.find(entry => safeText(entry?.value, "") === safeText(modelKey, "")); const resolvedProfile = profile || (model?.requiresGpu ? "big" : "small"); - const payload = await fetchJson("/api/models/active", { + selectionWrite = fetchJson("/api/models/active", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ profile: resolvedProfile, model: modelKey }), }); - notify(payload.message || `Selected "${modelKey}".`); + try { return await selectionWrite; } finally { selectionWrite = null; } } async function startDownload(modelKey) { @@ -391,6 +413,9 @@ async function refreshManifest() { } async function runAction(action, modelKey = "") { + const selecting = ["select", "select-small", "select-big"].includes(action); + if (!isModelRouteActive() || (selecting && state.selectionUncertain)) return; + const generation = viewGeneration; if (state.actionBusy) { notify("Please wait for the current action to finish.", "error"); return; @@ -411,9 +436,14 @@ async function runAction(action, modelKey = "") { } if (action === "select" || action === "select-small" || action === "select-big") { - if (!modelKey) return; + // Empty Active Big explicitly disables that profile; other selections require a model. + if (!modelKey && action !== "select-big") return; const profile = action === "select-small" ? "small" : action === "select-big" ? "big" : ""; - await setActiveModel(modelKey, profile); + state.selectionUncertain = true; + ++statusGeneration; // Invalidate pre-write polls, including their finalisers. + const payload = await setActiveModel(modelKey, profile); + if (generation !== viewGeneration || !isModelRouteActive()) return; + notify(payload.message || `Selected "${modelKey}".`); } else if (action === "download") { if (!modelKey) return; await startDownload(modelKey); @@ -434,18 +464,21 @@ async function runAction(action, modelKey = "") { await setUserFavorite(modelKey, false); } + if (generation !== viewGeneration || !isModelRouteActive()) return; await fetchStatus(); } catch (error) { + if (generation !== viewGeneration || !isModelRouteActive()) return; notify(error?.message || String(error), "error"); + // A failed response does not establish whether the server accepted the write. + await fetchStatus(); } finally { - state.actionBusy = false; + if (generation === viewGeneration && isModelRouteActive()) state.actionBusy = false; } } function bindDomHandlers() { if (window.__modelManagerHandlersBound) return; window.__modelManagerHandlersBound = true; - document.addEventListener("click", event => { if (!isModelRouteActive()) return; @@ -475,6 +508,8 @@ function bindDomHandlers() { const modelKey = safeText(target.value, ""); const profile = target.id === "mm-active-big-model-select" ? "big" : "small"; if (!modelKey && profile !== "big") return; + // Native selects change before their event; display only verified state. + target.value = profile === "big" ? state.activeBigModel : state.activeSmallModel; runAction(`select-${profile}`, modelKey).catch(() => {}); return; } @@ -532,7 +567,7 @@ function renderActions(model) { if (model.installed) { return html` - + ${model.builtin ? "" : html``} @@ -599,19 +634,41 @@ function renderSeriesSection(seriesName, models) { `; } +function ensureModelView() { + if (document.querySelector(".mm-wrapper")) return; + const generation = ++viewGeneration; + ++statusGeneration; + clearTimeout(pollingHandle); + pollingHandle = null; + // Arrow has no unmount hook. Observe this mount's removal, not just pathname: + // a quick leave-and-return must not revive an old write/readback continuation. + queueMicrotask(() => { + if (generation !== viewGeneration) return; + const observer = new MutationObserver(() => { + if (generation !== viewGeneration) { observer.disconnect(); return; } + // Arrow may replace the wrapper during an ordinary reactive render. + if (document.querySelector(".mm-wrapper") && isModelRouteActive()) return; + observer.disconnect(); + if (generation !== viewGeneration) return; + ++viewGeneration; + ++statusGeneration; + clearTimeout(pollingHandle); + pollingHandle = null; + }); + observer.observe(document.body, { childList: true, subtree: true }); + state.actionBusy = false; + state.selectionUncertain = true; + refreshAll(); + ensurePolling(); + }); +} + export function ModelManager() { if (!initialized) { initialized = true; bindDomHandlers(); - logDebug("Initializing component"); - refreshAll().catch(error => { - state.error = error?.message || String(error); - state.loading = false; - state.refreshing = false; - logDebug("Initial refresh failed", state.error); - }); } - ensurePolling(); + ensureModelView(); return html`
UI DEVELOPMENT PREVIEW — SYNTHETIC CATALOGUE, NO DEVICE CONNECTION
`; + await context.route('**/*',async route=>{ + const url=new URL(route.request().url()); + if(url.origin!=='http://galaxy.invalid') return route.abort(); + if(url.pathname.startsWith('/api/')) { + if(route.request().method()!=='GET') { + writes.push({path:url.pathname,body:route.request().postDataJSON()}); + if(url.pathname==='/api/models/active') { assert.equal(writes.at(-1).body.profile,'big'); if (!(process.env.MODEL_FAULT === 'reject' && writes.length === 1)) activeBig=writes.at(-1).body.model; + if (process.env.MODEL_FAULT && writes.length === 1) return route.fulfill({status:503,json:{error:'Synthetic uncertain response'}}); + } + else throw new Error('Unexpected write '+url.pathname); + return route.fulfill({json:{message:'Synthetic UI fixture action'}}); + } + assert.equal(url.pathname,'/api/models/status');polls++; + return route.fulfill({json:{models,currentModel:'fixture-0',activeBigModel:activeBig,activeSmallModel:'',summary:{installed:1,missing:35,total:36},downloading,modelToDownload:target,progress:downloading?`${polls}%`:'',isOnroad:false}}); + } + if(url.pathname.startsWith('/assets/')) { + try {return route.fulfill({body:readFileSync(root+url.pathname),contentType:url.pathname.endsWith('.css')?'text/css':'text/javascript'});} catch(e) {errors.push(String(e));return route.abort();} + } + return route.fulfill({contentType:'text/html',body:preview}); + }); + const page=await context.newPage();page.on('pageerror',e=>{errors.push(e.message); console.error('PAGEERROR',e.message);}); + await page.goto('http://galaxy.invalid/manage_models'); + const rowSelector=mobile?'.gx-card-grid > section':'.mm-row'; + const rows=page.locator(rowSelector); + await rows.first().waitFor(); + const select=mobile?page.locator('.gx-row').filter({has:page.getByText('Active Big',{exact:true})}).locator('select'):page.locator('#mm-active-big-model-select'); + assert.equal(await select.inputValue(),'fixture-0'); + await select.selectOption(''); + await new Promise(r=>setTimeout(r,350)); + assert.deepEqual(writes,[{path:'/api/models/active',body:{profile:'big',model:''}}],`${surface}: None must disable Active Big through API`); + assert.equal(await select.inputValue(),process.env.MODEL_FAULT === 'reject' ? 'fixture-0' : '', 'Selection must match authoritative status after uncertain response'); + if (process.env.MODEL_FAULT) assert.ok(polls >= 2, 'Uncertain write requires immediate authoritative readback'); + await select.selectOption('fixture-2'); + await new Promise(r=>setTimeout(r,350)); + assert.equal(writes.length,2); + assert.deepEqual(writes[1],{path:'/api/models/active',body:{profile:'big',model:'fixture-2'}}); + await page.reload();await rows.first().waitFor(); + assert.equal(await select.inputValue(),'fixture-2'); + results.push({surface,width,writes}); + await context.close(); + } + assert.deepEqual(errors,[]); + console.log(JSON.stringify({results,errors},null,2)); +} finally {await browser.close();} diff --git a/starpilot/system/the_galaxy/tests/test_model_manager_state.mjs b/starpilot/system/the_galaxy/tests/test_model_manager_state.mjs new file mode 100644 index 0000000000..c3bc7b54bd --- /dev/null +++ b/starpilot/system/the_galaxy/tests/test_model_manager_state.mjs @@ -0,0 +1,44 @@ +// Exercise shipped component methods with deferred requests; no device I/O. +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +const source = readFileSync(new URL('../assets/mobile/js/views/ModelManager.js', import.meta.url), 'utf8'); +const hardware = await import('../assets/components/tools/model_hardware.js'); +let status, write, prompt, requests, notices; +const deferred = () => { let resolve, reject; const promise = new Promise((a,b)=>{resolve=a;reject=b}); return {promise,resolve,reject}; }; +const payload = (overrides={}) => ({models:[{value:'gpu',requiresGpu:true,gpuAvailable:false,installed:false},{value:'small',requiresGpu:false,installed:true}],activeBigModel:'gpu',activeSmallModel:'small',currentModel:'small',summary:{},...overrides}); +const api = { + getModelStatus: () => status(), + setActiveModel: (profile,model) => { requests.push({profile,model}); return write(); }, + startModelDownload: (model,allow) => { requests.push({model,allow});return write(); }, + downloadAllModels: allow => { requests.push({all:true,allow});return write(); }, + postAction: action => { requests.push({action});return write(); }, +}; +const component = new Function('api','showSnackbar','usePolling','GalaxyConfirm','openGalaxyHelpDialog',...Object.keys(hardware), source.replace(/^import .*$/gm,'').replace('export const ModelManager', 'const ModelManager')+';return ModelManager;')(api,(...a)=>notices.push(a),()=>{},()=>{},()=>prompt(),...Object.values(hardware)); +function instance() { const vm=component.data();for(const [key,value] of Object.entries(component.methods))vm[key]=value.bind(vm);return vm; } +async function reset() {requests=[];notices=[];status=async()=>payload();write=async()=>({});prompt=async()=>true;const vm=instance();await vm.refresh();return vm;} +// Filters combine without altering the catalogue or selection. +let vm=await reset();vm.hardwareFilter='comma';vm.userFilter='all';vm.communityFilter='all';assert.deepEqual(component.computed.sorted.call(vm).map(m=>m.value),['small']);assert.equal(vm.activeBigModel,'gpu');assert.deepEqual(requests,[]); +// GPU approval is per action; cancellation sends no write and releases busy. +prompt=async()=>false;await vm.runAction('download',vm.models[0]);assert.deepEqual(requests,[]);assert.equal(vm.busy,''); +prompt=async()=>true;await vm.runAction('download',vm.models[0]);assert.deepEqual(requests,[{model:'gpu',allow:true}]); +// Approval must recheck road state, competing downloads and status failures. +for(const changed of [{isOnroad:true},{downloading:true},null]) { vm=await reset();status=changed?async()=>payload(changed):async()=>{throw Error('read failed')};await vm.runAction('download',vm.models[0]);assert.deepEqual(requests,[]);assert.equal(vm.busy,''); } +// Ordinary downloads never reuse approval, and failed writes release busy. +vm=await reset();write=async()=>{throw Error('download failed')};await vm.runAction('download',vm.models[1]);assert.deepEqual(requests,[{model:'small',allow:false}]);assert.equal(vm.busy,''); +vm=await reset();await vm.runAction('downloadAll');assert.deepEqual(requests,[{all:true,allow:true}]); +// A pre-write poll cannot overwrite authoritative post-write selection. +vm=await reset();let old=deferred();status=()=>old.promise;let poll=vm.refresh();write=async()=>({});status=async()=>payload({activeBigModel:''});await vm.runAction('select-big');old.resolve(payload());await poll;assert.equal(vm.activeBigModel,'');assert.equal(vm.selectionUncertain,false); +// An uncertain write plus failed readback locks only selection until retry. +vm=await reset();write=async()=>{throw Error('uncertain write')};status=async()=>{throw Error('read failure')};await vm.runAction('select-big');assert.equal(vm.selectionUncertain,true);await vm.runAction('select-big');assert.equal(requests.length,1);await vm.runAction('cancel');assert.equal(requests.length,2);status=async()=>payload({activeBigModel:''});await vm.refresh();assert.equal(vm.selectionUncertain,false);assert.equal(vm.activeBigModel,''); +// A remount must wait for an already sent write and ignore old notifications. +vm=await reset();const pending=deferred();write=()=>pending.promise;const action=vm.runAction('select-big');component.beforeUnmount.call(vm);const next=instance();let reads=0;status=async()=>{reads++;return payload({activeBigModel:''})};const refreshed=next.refresh();await Promise.resolve();assert.equal(reads,0);pending.resolve({message:'old success'});await action;await refreshed;assert.equal(next.activeBigModel,'');assert.equal(next.selectionUncertain,false);assert.deepEqual(notices,[]); +// Unmounted confirmation and overlapping action cannot submit another request. +vm=await reset();const confirmation=deferred();prompt=()=>confirmation.promise;const downloading=vm.runAction('download',vm.models[0]);await vm.runAction('downloadAll');assert.deepEqual(requests,[]);component.beforeUnmount.call(vm);confirmation.resolve(true);await downloading;assert.deepEqual(requests,[]); +// Two concurrent polls resolve newest-first. +vm=await reset();const a=deferred(),b=deferred();status=()=>a.promise;const first=vm.refresh();status=()=>b.promise;const second=vm.refresh();b.resolve(payload({activeBigModel:'new'}));await second;a.resolve(payload({activeBigModel:'old'}));await first;assert.equal(vm.activeBigModel,'new'); +assert.ok(!source.includes('GalaxySelect'));assert.ok(!source.includes('model_metrics'));assert.ok(!source.includes('model_stats'));assert.ok(source.includes('selectionUncertain || status.isOnroad')); +console.log('PASS: 15 hardware/download/selection/lifecycle scenarios against shipped methods'); + +const {compile} = await import('../assets/vendor/vue/vue.esm-browser.js'); +assert.equal(typeof compile(component.template, {onError(error) {throw error;}}), 'function'); +console.log('PASS: shipped Vue compiler accepts the native-select Model Manager template'); diff --git a/starpilot/system/the_galaxy/tests/test_model_profile_payloads.py b/starpilot/system/the_galaxy/tests/test_model_profile_payloads.py new file mode 100644 index 0000000000..2d5177c8b4 --- /dev/null +++ b/starpilot/system/the_galaxy/tests/test_model_profile_payloads.py @@ -0,0 +1,23 @@ +"""Exercise shipped Flask handler with the existing isolated Params harness.""" +import pytest +from test_dashboard_stats import _load_server_module, FakeParams + + +@pytest.mark.parametrize('payload', [ + {'profile': 'big'}, {'profile': 'big', 'model': None}, + {'profile': 'big', 'model': False}, {'profile': 'big', 'model': 0}, + {'profile': 'big', 'model': []}, {'profile': 'big', 'model': {}}, + [], ['big'], True, 1, +]) +def test_malformed_selection_never_disables_big(monkeypatch, payload): + server = _load_server_module() + assert server._import_galaxy_web_symbols() + app = server.Flask('model_payload_test') + server.setup(app) + params = FakeParams({'IsOnroad': False, 'ActiveBigModel': 'installed-big', + 'ActiveBigModelName': 'Installed Big', 'ActiveBigModelVersion': 'v16'}) + monkeypatch.setattr(server, 'params', params) + before = params.values.copy() + response = app.test_client().put('/api/models/active', json=payload) + assert response.status_code == 400 + assert params.values == before diff --git a/starpilot/system/the_galaxy/tests/test_model_selection_contract.py b/starpilot/system/the_galaxy/tests/test_model_selection_contract.py new file mode 100644 index 0000000000..88ae9099d0 --- /dev/null +++ b/starpilot/system/the_galaxy/tests/test_model_selection_contract.py @@ -0,0 +1,16 @@ +"""Actual Flask Active Big contract with in-memory Params, no native/device I/O.""" +import pytest +from test_personality_profiles_api import _client, the_galaxy + +@pytest.mark.parametrize('profile,onroad,expected', [('big',False,200),('small',False,400),('big',True,403),('invalid',False,400)]) +def test_active_big_empty_contract(monkeypatch, profile, onroad, expected): + client, params = _client(monkeypatch, {'IsOnroad':onroad}) + calls=[] + monkeypatch.setattr(the_galaxy,'disable_big_model_profile',lambda p:calls.append(p)) + monkeypatch.setattr(the_galaxy,'normalize_model_lab_config',lambda raw:{'enabled':False}) + response=client.put('/api/models/active',json={'profile':profile,'model':''}) + assert response.status_code == expected, response.get_json() + assert len(calls) == (expected == 200) + if expected == 200: + assert response.get_json()['model'] == '' + assert response.get_json()['profile'] == 'big' diff --git a/starpilot/system/the_galaxy/the_galaxy.py b/starpilot/system/the_galaxy/the_galaxy.py index 9f2176aa0d..eecd23f4cd 100644 --- a/starpilot/system/the_galaxy/the_galaxy.py +++ b/starpilot/system/the_galaxy/the_galaxy.py @@ -5171,6 +5171,8 @@ class GalaxySlugMiddleware: def setup(app): + from openpilot.starpilot.assets.model_sizes import ModelSizes + model_sizes = ModelSizes() if not isinstance(app.wsgi_app, GalaxySlugMiddleware): app.wsgi_app = GalaxySlugMiddleware(app.wsgi_app) @@ -6887,7 +6889,10 @@ def setup(app): if params.get_bool("IsOnroad"): return jsonify({"error": "Cannot change active models while driving."}), 403 - data = request.get_json(silent=True) or {} + data = request.get_json(silent=True) + # An explicit empty model disables Active Big; malformed values must not. + if not isinstance(data, dict) or not isinstance(data.get("model"), str): + return jsonify({"error": "An explicit model string is required."}), 400 profile = str(data.get("profile") or "").strip().lower() if profile not in ("small", "big"): return jsonify({"error": "Model profile must be 'small' or 'big'."}), 400 @@ -7529,7 +7534,9 @@ def setup(app): }) models.sort(key=lambda model: (model["series"].lower(), model["label"].lower())) - return models + return model_sizes.annotate(models, MODELS_PATH, + Path(__file__).resolve().parents[3] / "selfdrive/modeld/models/driving_tinygrad.pkl", + artifact_metadata, model_accelerator_artifact_filename) @app.route("/api/routes", methods=["GET"]) def list_routes():