From 976a0cff162eb368ea2c322bb419d7d9dde201df Mon Sep 17 00:00:00 2001 From: Nelson Chen Date: Sun, 23 Aug 2026 10:07:14 -0700 Subject: [PATCH 1/2] Add storage balloon pre-check --- README.md | 2 + src/app/App.test.jsx | 53 ++++++++++++- src/app/Flash.jsx | 172 ++++++++++++++++++++++++++++++++++++++-- src/utils/image.js | 88 ++++++++++++++++++-- src/utils/image.test.js | 103 ++++++++++++++++++++++++ src/utils/manager.js | 26 +++--- 6 files changed, 416 insertions(+), 28 deletions(-) create mode 100644 src/utils/image.test.js diff --git a/README.md b/README.md index bdb6132a..0ca07a96 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ This tool allows you to flash AGNOS onto your comma device. Uses [qdl.js](https://github.com/commaai/qdl.js). +Before flashing, the tool verifies local disk space by writing a temporary 5.25 GiB blank file. The file is held as a reservation until flashing begins, then deleted before images are downloaded and staged for A/B partitions. + ## Development ```bash diff --git a/src/app/App.test.jsx b/src/app/App.test.jsx index 206de720..0a738af8 100644 --- a/src/app/App.test.jsx +++ b/src/app/App.test.jsx @@ -1,10 +1,57 @@ -import { Suspense } from 'react' -import { expect, test } from 'vitest' -import { render, screen } from '@testing-library/react' +import { StrictMode, Suspense } from 'react' +import { expect, test, vi } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' import App from '.' +import { runStorageProbe } from '../utils/image' + +vi.mock('../utils/image', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + cleanupStorageProbe: vi.fn().mockResolvedValue(undefined), + runStorageProbe: vi.fn(({ signal }) => new Promise((resolve, reject) => { + const passTimer = setTimeout(() => resolve({ writtenBytes: 1 }), 10) + signal.addEventListener('abort', () => { + clearTimeout(passTimer) + reject(new DOMException('Storage test canceled', 'AbortError')) + }, { once: true }) + })), + } +}) test('renders without crashing', () => { render() expect(screen.getByText('flash.comma.ai')).toBeInTheDocument() }) + +test('shows the storage check without private-browsing guidance', async () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Start' })) + + expect(screen.getByText('Storage check')).toBeInTheDocument() + expect(screen.queryByText(/Do not use Incognito or InPrivate browsing/)).not.toBeInTheDocument() + expect(await screen.findByText('Passed')).toBeInTheDocument() +}) + +test('shows private-browsing guidance after a storage failure', async () => { + vi.mocked(runStorageProbe).mockRejectedValueOnce(new Error('Quota exceeded')) + render() + fireEvent.click(screen.getByRole('button', { name: 'Start' })) + + expect(screen.queryByText(/Do not use Incognito or InPrivate browsing/)).not.toBeInTheDocument() + expect(await screen.findByText(/Free at least 6 GiB of space on this device/)).toBeInTheDocument() + expect(screen.getByText(/If you are using Incognito or InPrivate browsing/)).toBeInTheDocument() +}) + +test('does not cancel the storage pre-check during the Strict Mode effect cycle', async () => { + render( + + + , + ) + fireEvent.click(screen.getByRole('button', { name: 'Start' })) + + expect(await screen.findByText('Passed')).toBeInTheDocument() + expect(screen.queryByText('Canceled. Retry the storage pre-check.')).not.toBeInTheDocument() +}) diff --git a/src/app/Flash.jsx b/src/app/Flash.jsx index 18a650b6..c3a21871 100644 --- a/src/app/Flash.jsx +++ b/src/app/Flash.jsx @@ -3,7 +3,11 @@ import posthog from 'posthog-js' import * as Sentry from '@sentry/react' import { FlashManager, StepCode, ErrorCode, DeviceType } from '../utils/manager' -import { useImageManager } from '../utils/image' +import { + cleanupStorageProbe, + runStorageProbe, + useImageManager, +} from '../utils/image' import { isLinux, isWindows } from '../utils/platform' import config from '../config' @@ -542,12 +546,154 @@ function WebUSBConnect({ onConnect }) { ) } +const STORAGE_PROBE_MARKER = 'comma-flash-storage-probe-active' +const FORCE_STORAGE_PROBE_FAILURE = import.meta.env.DEV && new URLSearchParams(window.location.search).has('storageFail') +const STORAGE_PROBE_FAILURE_MESSAGE = 'Storage check failed. Free at least 6 GiB of space on this device and retry. If you are using Incognito or InPrivate browsing, switch to a regular window.' + +function StoragePreCheck({ storageCleanupComplete, onProbeStatusChange }) { + const [probeStatus, setProbeStatus] = useState('idle') + const [probeProgress, setProbeProgress] = useState(0) + const [probeMessage, setProbeMessage] = useState('') + const probeStatusRef = useRef('idle') + const probeAbortRef = useRef(null) + const probeRunRef = useRef(0) + + const updateProbeStatus = (nextStatus, nextMessage = '') => { + probeStatusRef.current = nextStatus + setProbeStatus(nextStatus) + setProbeMessage(nextMessage) + onProbeStatusChange(nextStatus) + } + + const startProbe = async () => { + if (probeStatusRef.current === 'running') return + const runId = ++probeRunRef.current + const abortController = new AbortController() + probeAbortRef.current = abortController + setProbeProgress(0) + updateProbeStatus('running') + + try { + localStorage.setItem(STORAGE_PROBE_MARKER, '1') + } catch { + // The OPFS write itself remains authoritative if localStorage is unavailable. + } + + try { + if (FORCE_STORAGE_PROBE_FAILURE) throw new Error('Forced storage probe failure') + await runStorageProbe({ + signal: abortController.signal, + onProgress: (progress) => { + if (probeRunRef.current !== runId) return + setProbeProgress(progress) + }, + }) + if (probeRunRef.current !== runId) return + try { localStorage.removeItem(STORAGE_PROBE_MARKER) } catch { /* ignored */ } + updateProbeStatus('passed', 'Passed') + } catch (error) { + if (probeRunRef.current !== runId) return + try { localStorage.removeItem(STORAGE_PROBE_MARKER) } catch { /* ignored */ } + if (error?.name === 'AbortError') { + updateProbeStatus('failed', 'Canceled. Retry the storage pre-check.') + } else { + updateProbeStatus('failed', STORAGE_PROBE_FAILURE_MESSAGE) + } + } finally { + if (probeRunRef.current === runId) probeAbortRef.current = null + } + } + + useEffect(() => { + if (!storageCleanupComplete || probeStatusRef.current !== 'idle') return + + // Deferring one task prevents React Strict Mode's development-only effect + // cleanup from starting and immediately aborting the real storage probe. + const startTimer = setTimeout(() => { + let previousProbeInterrupted = false + try { + previousProbeInterrupted = localStorage.getItem(STORAGE_PROBE_MARKER) === '1' + localStorage.removeItem(STORAGE_PROBE_MARKER) + } catch { + // Continue with a new probe when localStorage is unavailable. + } + if (previousProbeInterrupted) { + updateProbeStatus('failed', STORAGE_PROBE_FAILURE_MESSAGE) + return + } + + startProbe() + }, 0) + + return () => clearTimeout(startTimer) + }, [storageCleanupComplete]) + + useEffect(() => () => { + if (probeStatusRef.current === 'running') probeAbortRef.current?.abort() + }, []) + + const probePassed = probeStatus === 'passed' + const probeFailed = probeStatus === 'failed' + + return ( +
+
+
+

Storage check

+ {!probeFailed && ( +

+ {probePassed ? '5.25 GiB reserved for flashing.' : 'Reserving 5.25 GiB before flashing...'} +

+ )} +
+ {probePassed && ( + Passed + )} + {probeStatus === 'running' && ( + + )} +
+ {probeStatus === 'running' && ( +
+
+
+ )} + {probeFailed && ( +
+

{probeMessage}

+ +
+ )} +
+ ) +} + // Device picker component -function DevicePicker({ onSelect }) { +function DevicePicker({ onSelect, storageCleanupComplete }) { const [selected, setSelected] = useState(null) + const [probeStatus, setProbeStatus] = useState('idle') + const storageReady = probeStatus === 'passed' return ( -
+

Which device are you flashing?

Select your comma device

@@ -579,11 +725,16 @@ function DevicePicker({ onSelect }) {
+ +