From c4f07d57210f0993543d8e0ecaa43b13964ea839 Mon Sep 17 00:00:00 2001 From: Elias Bakken Date: Fri, 18 Sep 2026 10:56:35 +0200 Subject: [PATCH] Put the state back when a transfer cannot be started (#166) Pressing Download showed a progress bar that counted up and then froze at "0m:22s 10.3 MB/s 0m:9s" with a Cancel button, while nothing was downloading: no .part on the drive, no "Starting download" in reflash.log, and get_progress reporting IDLE with the figures of the previous, cancelled download. The request had never reached the board, and the UI had invented a transfer out of stale polled values. Only Cancel or a reload cleared it. downloadSelected, uploadSelected, startMagic and startMagicUpload all set this.state before asking the board, so the button can turn into Cancel, and all four handled the answer in a .then with no catch. A rejected request therefore skipped checkProgress - so no polling ever started - and left the state on DOWNLOADING, with the bar rendering whatever the store still held. Same shape as #131, where one failed request ended the polling for good. installSelected and backupSelected do not set the state, so they failed silently instead. That is worse in one specific way: start_installation and start_magic answer 409 with "The eMMC stopped responding. Power cycle the board and try again." (#137), and that sentence was being dropped on the floor. It is the most useful thing the board can say, and the user saw nothing at all. All six now share transferFailed(), which restores IDLE, drops this tab's claim on an upload that never started, and reports the board's own words when it answered or the transport error when it did not. Co-Authored-By: Claude Opus 5 --- client/src/App.vue | 98 +++++++++++++-------- client/tests/unit/TransferStart.spec.js | 108 ++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 34 deletions(-) create mode 100644 client/tests/unit/TransferStart.spec.js diff --git a/client/src/App.vue b/client/src/App.vue index 30c81ae..8e73bf5 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -478,6 +478,30 @@ export default { this.files = files; this.file = files.file; }, + // Every start button below asks the board to begin something. Four of them + // set the state optimistically first, so the button can turn into Cancel - + // and nothing put that state back when the request failed. The UI then sat + // on a progress bar, rate and countdown included, rendered from the last + // poll of a previous transfer, for one that had never started (#166). The + // two that do not set the state failed silently instead, which swallowed + // the 409 that start_installation and start_magic answer with when the eMMC + // has stopped responding - the most useful sentence the board can say + // (#137). + transferFailed(what, err) { + this.state = "IDLE"; + // A start that failed owns nothing, so the unload beacon must not fire a + // cancel at whatever is running by the time this tab closes. + this.ownsUpload = false; + // The board's own words when it answered, the transport error when it + // never did. + const detail = + (err && err.response && err.response.data) || (err && err.message) || ""; + this.$waveui.notify( + `Could not start the ${what}.` + (detail ? ` ${detail}` : ""), + "error", + 0 + ); + }, async apiCall(call) { var self = this; // Whatever ends the upload also ends this tab's ownership of it, so a @@ -512,17 +536,18 @@ export default { this.resetProgressBars(); this.state = "UPLOADING_MAGIC"; this.ownsUpload = true; - await axios - .put(`/api/upload_magic_start`, { + try { + const response = await axios.put(`/api/upload_magic_start`, { filename: self.file.name, size: self.file.size, start_time: Date.now(), - }) - .then(function (response) { - self.status = response.data["success"]; - self.magicUploadLocalFile(); - self.checkProgress(); }); + self.status = response.data["success"]; + self.magicUploadLocalFile(); + self.checkProgress(); + } catch (err) { + this.transferFailed("magic upload", err); + } } else { this.apiCall("upload_cancel"); } @@ -638,17 +663,18 @@ export default { this.resetProgressBars(); this.state = "UPLOADING"; this.ownsUpload = true; - await axios - .put(`/api/upload_start`, { + try { + const response = await axios.put(`/api/upload_start`, { filename: self.file.name, size: self.file.size, start_time: Date.now(), - }) - .then(function (response) { - self.status = response.data["success"]; - self.uploadLocalFile(); - self.checkProgress(); }); + self.status = response.data["success"]; + self.uploadLocalFile(); + self.checkProgress(); + } catch (err) { + this.transferFailed("upload", err); + } } else { this.apiCall("upload_cancel"); } @@ -766,16 +792,17 @@ export default { let self = this; if (this.state == "IDLE") { this.state = "MAGIC"; - await axios - .put(`/api/start_magic`, { + try { + await axios.put(`/api/start_magic`, { filename: this.selectedGithubImage["name"], size: this.selectedGithubImage["size"], url: this.selectedGithubImage["url"], start_time: Date.now(), - }) - .then(() => { - self.checkProgress(); }); + self.checkProgress(); + } catch (err) { + this.transferFailed("magic flash", err); + } } else { axios.put(`/api/cancel_magic`); } @@ -885,16 +912,17 @@ export default { let self = this; if (this.state == "IDLE") { this.state = "DOWNLOADING"; - await axios - .put(`/api/start_download`, { + try { + await axios.put(`/api/start_download`, { filename: this.selectedGithubImage["name"], size: this.selectedGithubImage["size"], url: this.selectedGithubImage["url"], start_time: Date.now(), - }) - .then(() => { - self.checkProgress(); }); + self.checkProgress(); + } catch (err) { + this.transferFailed("download", err); + } } else { axios.put(`/api/cancel_download`); } @@ -952,28 +980,30 @@ export default { }, async installSelected() { let self = this; - await axios - .put(`/api/start_installation`, { + try { + await axios.put(`/api/start_installation`, { filename: this.selectedLocalImage, start_time: Date.now(), - }) - .then(() => { - self.checkProgress(); }); + self.checkProgress(); + } catch (err) { + this.transferFailed("installation", err); + } }, async backupSelected() { this.setProgress({ progress: 0 }); this.resetProgressBars(); this.$refs.installprogressbar.update(); let self = this; - await axios - .put(`/api/start_backup`, { + try { + await axios.put(`/api/start_backup`, { filename: this.backupFile, start_time: Date.now(), - }) - .then(() => { - self.checkProgress(); }); + self.checkProgress(); + } catch (err) { + this.transferFailed("backup", err); + } }, rebootBoard() { this.showOverlay = true; diff --git a/client/tests/unit/TransferStart.spec.js b/client/tests/unit/TransferStart.spec.js new file mode 100644 index 0000000..9f8738d --- /dev/null +++ b/client/tests/unit/TransferStart.spec.js @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import axios from 'axios'; +import App from '@/App.vue'; + +vi.mock('axios'); + +// Every one of these buttons asks the board to start something. If that request +// does not land, the ones that set the state optimistically used to leave the UI +// on a progress bar for a transfer that never started (#166), and the ones that +// do not set it said nothing at all - including when the board had answered 409 +// with a reason worth reading. +function stand(over = {}) { + return { + state: 'IDLE', + ownsUpload: false, + status: null, + backupFile: 'backup', + file: { name: 'image.img.xz', size: 1024 }, + selectedGithubImage: { name: 'image.img.xz', size: 1024, url: 'http://example/image.img.xz' }, + selectedLocalImage: 'image.img.xz', + checkProgress: vi.fn(), + resetProgressBars: vi.fn(), + setProgress: vi.fn(), + uploadLocalFile: vi.fn(), + magicUploadLocalFile: vi.fn(), + $refs: { installprogressbar: { update: vi.fn() } }, + $waveui: { notify: vi.fn() }, + // The real one, not a stub: the failure handling is what is under test + // here, and on a live component every method sits on the same instance. + transferFailed: App.methods.transferFailed, + ...over, + }; +} + +const started = [ + ['downloadSelected', 'DOWNLOADING'], + ['uploadSelected', 'UPLOADING'], + ['startMagic', 'MAGIC'], + ['startMagicUpload', 'UPLOADING_MAGIC'], +]; + +describe('a transfer that cannot be started does not leave the UI pretending (#166)', () => { + beforeEach(() => vi.clearAllMocks()); + + for (const [method, busyState] of started) { + it(`${method}: puts the state back to IDLE when the request fails`, async () => { + axios.put.mockRejectedValue(new Error('Network Error')); + const self = stand(); + + await App.methods[method].call(self); + + expect(self.state).toBe('IDLE'); + expect(self.checkProgress).not.toHaveBeenCalled(); + expect(self.$waveui.notify).toHaveBeenCalled(); + }); + + it(`${method}: leaves the state alone when the request succeeds`, async () => { + axios.put.mockResolvedValue({ data: { success: true } }); + const self = stand(); + + await App.methods[method].call(self); + + expect(self.state).toBe(busyState); + expect(self.$waveui.notify).not.toHaveBeenCalled(); + }); + } + + // The uploads also claim ownership of the transfer for this tab, which drives + // the cancel-on-unload beacon. A start that failed owns nothing. + it('a failed upload start does not leave this tab owning the upload', async () => { + axios.put.mockRejectedValue(new Error('Network Error')); + const self = stand(); + + await App.methods.uploadSelected.call(self); + + expect(self.ownsUpload).toBe(false); + }); +}); + +// start_installation and start_magic answer 409 with a sentence explaining that +// the eMMC stopped responding and the board needs a power cycle (#137). It is +// the most useful thing the server can say, and it was being dropped. +describe('the board\'s refusal reaches the user', () => { + beforeEach(() => vi.clearAllMocks()); + + it('installSelected surfaces the message from a refused install', async () => { + axios.put.mockRejectedValue({ + response: { status: 409, data: 'The eMMC stopped responding. Power cycle the board and try again.' }, + }); + const self = stand(); + + await App.methods.installSelected.call(self); + + expect(self.checkProgress).not.toHaveBeenCalled(); + const said = self.$waveui.notify.mock.calls.map(([msg]) => msg).join(' '); + expect(said).toContain('eMMC stopped responding'); + }); + + it('backupSelected reports a backup that could not be started', async () => { + axios.put.mockRejectedValue(new Error('Network Error')); + const self = stand(); + + await App.methods.backupSelected.call(self); + + expect(self.checkProgress).not.toHaveBeenCalled(); + expect(self.$waveui.notify).toHaveBeenCalled(); + }); +});