From bb7b59260dae25e81ef9b6bb7dfe7a58f9363559 Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Tue, 29 Sep 2026 09:32:27 -0400 Subject: [PATCH 1/4] web: add /run page to start and watch workloads The dashboard at / stays read-only (its no-write-controls contract test is unchanged) and gains a plain link to /run, following the /setup precedent. /run shows one run-state indicator taken from /api/v1/status: READY, RUNNING, ABORTING, PASS, FAIL or ABORTED with the workload and run ID. The only browser-side state is STARTING, shown while the page's own POST is in flight. A 201 is not treated as RUNNING; the page refreshes status and waits for the device to report the run. Staleness uses the existing 5 s / 15 s freshness rule, so a lost connection shows STALE and then DISCONNECTED instead of a stale RUNNING. Start buttons exist only for workloads whose request the page knows how to build (the eight duration-only workloads, and NET_* with host, port and rate). Any other available workload is listed without a control. Buttons are disabled while starting, while the device reports a run active, and while the device is not answering; the device still rejects conflicting requests itself. Abort appears only for a running run and posts to /api/v1/runs/{id}/abort. No API, workload, or run-state change. --- README.md | 8 + firmware/targets/esp32s3/main/main.c | 227 ++++++++++++++++ tests/test_contract.py | 20 ++ tests/test_run_page_js.py | 369 +++++++++++++++++++++++++++ web/index.html | 3 + 5 files changed, 627 insertions(+) create mode 100644 tests/test_run_page_js.py diff --git a/README.md b/README.md index ace6d47..abb12ce 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,14 @@ access-point password`. To also join a 2.4 GHz network, open and password in a local, ignored `sdkconfig`. Credentials entered on the setup page persist in NVS and take precedence over Kconfig. +The dashboard at `/` is read-only. `http://192.168.4.1/run` starts and aborts +workloads and shows the device's run state: READY, RUNNING, ABORTING, PASS, +FAIL or ABORTED, taken from `/api/v1/status`. It shows STARTING only while its +own start request is in flight, and STALE or DISCONNECTED when the device stops +answering. It offers Start only for workloads whose request it knows how to +build; the device still validates every request and refuses a second run while +one is active. + Each board advertises its own mDNS name on both interfaces, derived from the same MAC suffix as its access point: `DragonBench-D685F0` answers as `http://dragonbench-d685f0.local/`. Several boards can share one network diff --git a/firmware/targets/esp32s3/main/main.c b/firmware/targets/esp32s3/main/main.c index f869aa5..e24716e 100644 --- a/firmware/targets/esp32s3/main/main.c +++ b/firmware/targets/esp32s3/main/main.c @@ -628,6 +628,8 @@ static const char landing[] = "details{border-top:1px solid var(--border);padding-top:8px;margin-top:8px}" "summary{cursor:pointer;color:var(--muted-fg);font-size:.78rem;width:fit-content;font-family:ui-monospace,SFMono-Regular,Consolas,monospace}" "pre{margin:8px 0 0;max-height:320px;overflow:auto;background:var(--code);border:1px solid var(--border);border-radius:var(--radius-sm);padding:10px;font:.72rem/1.5 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre}" +"a{color:var(--primary)}" +".panel-link{margin:8px 0 0}" "footer{padding:16px 0 24px;color:var(--muted-fg);text-align:center;font-size:.76rem}" "@media (max-width:900px){.metric-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}" "@media (max-width:620px){.app-header{flex-direction:column;align-items:start}.api-status{justify-items:start}.metric-grid{grid-template-columns:1fr}.sensor-row{flex-direction:column;align-items:start}}" @@ -684,6 +686,7 @@ static const char landing[] = " " " " "
" +"

Start and watch runs

" "
Raw /api/v1/workloads
unavailable
" " " "" @@ -906,6 +909,229 @@ static const char setup_page[] = static esp_err_t setup_get(httpd_req_t *req) { return send_page(req, setup_page); } +// Operator page for starting and watching runs. The dashboard at / stays +// read-only; this page is the one place in the UI that POSTs to /api/v1/runs. +static const char run_page[] = +"" +"" +"" +"" +"" +"" +"DragonBench workloads" +"" +"" +"" +"
" +"
" +"

Run workloads

" +" Back to the dashboard" +"
" +"
" +" CONNECTING…" +"
" +"
Workload
—
" +"
Run ID
—
" +"
Device reports
—
" +"
Connection
Waiting for the device…
" +"
" +"

" +" " +"
" +"
" +"

Start a workload

" +"

One run at a time. The device checks every request and refuses a second run while one is active; these controls only mirror that.

" +"
" +" " +"
" +"
" +" Traffic peer (NET_TX, NET_RX, NET_BIDIRECTIONAL only)" +"
" +" " +" " +" " +"
" +"
" +"

Loading workloads…

" +"
" +"
" +"" +"" +""; + +static esp_err_t run_page_get(httpd_req_t *req) { return send_page(req, run_page); } + #define WIFI_NVS_NAMESPACE "db_wifi" static bool nvs_load_sta(char *ssid, size_t ssid_size, char *password, size_t password_size) { @@ -1033,6 +1259,7 @@ static esp_err_t network_sta_post(httpd_req_t *req) { static void start_http(void) { const httpd_uri_t routes[] = { {.uri="/",.method=HTTP_GET,.handler=landing_get}, {.uri="/setup",.method=HTTP_GET,.handler=setup_get}, + {.uri="/run",.method=HTTP_GET,.handler=run_page_get}, {.uri="/api/v1/device",.method=HTTP_GET,.handler=device_get}, {.uri="/api/v1/status",.method=HTTP_GET,.handler=status_get}, {.uri="/api/v1/sensors",.method=HTTP_GET,.handler=sensors_get}, {.uri="/api/v1/workloads",.method=HTTP_GET,.handler=workloads_get}, {.uri="/api/v1/runs",.method=HTTP_POST,.handler=runs_post}, diff --git a/tests/test_contract.py b/tests/test_contract.py index 22bc200..739d400 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -132,6 +132,26 @@ def test_landing_page_has_no_write_controls(self): for token in forbidden_ci: self.assertNotIn(token, lowered) + def test_run_page_is_served_and_linked_from_the_read_only_landing(self): + source = (ROOT / "firmware/targets/esp32s3/main/main.c").read_text() + self.assertIn('{.uri="/run",.method=HTTP_GET,.handler=run_page_get}', source) + web = (ROOT / "web/index.html").read_text() + for page in (web, _firmware_page("landing")): + self.assertIn('', page) + self.assertIn('', _firmware_page("run_page")) + + def test_run_page_has_no_external_dependency_or_innerhtml(self): + page = _firmware_page("run_page") + for token in ("http://", "https://", "cdn.", "//fonts.", "innerHTML"): + self.assertNotIn(token, page) + + def test_run_page_writes_only_through_the_run_api(self): + page = _firmware_page("run_page") + self.assertIn("fetch('/api/v1/runs',{method:'POST'", page) + self.assertIn("'/abort',{method:'POST'}", page) + self.assertEqual(page.count("method:'POST'"), 2) + self.assertNotIn("/api/v1/network", page) + def test_actuator_exclusion(self): source = "\n".join(p.read_text(errors="ignore") for p in (ROOT / "firmware").rglob("*.[ch]")) for forbidden in ("HEATER_GPIO", "FAN_GPIO", "heater_set", "fan_set"): diff --git a/tests/test_run_page_js.py b/tests/test_run_page_js.py new file mode 100644 index 0000000..2b4777e --- /dev/null +++ b/tests/test_run_page_js.py @@ -0,0 +1,369 @@ +import json +import os +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +from test_contract import _firmware_page + +# Runs the /run page script against a fake DOM, a fake clock, and a scripted +# device, and records what the operator would see after each step. +HARNESS = r""" +const PAGE = %s; +const flush = async () => { for (let i = 0; i < 20; i++) await new Promise(r => setImmediate(r)); }; +function makeEl() { + return { + textContent: '', value: '', hidden: false, disabled: false, dataset: {}, className: '', type: '', + children: [], listeners: {}, + get firstChild() { return this.children[0] || null; }, + appendChild(c) { this.children.push(c); return c; }, + removeChild(c) { this.children.splice(this.children.indexOf(c), 1); return c; }, + addEventListener(t, fn) { this.listeners[t] = fn; }, + }; +} +function page(device) { + const roles = {}; + const el = name => roles[name] || (roles[name] = makeEl()); + el('run-duration').value = '30'; + el('run-rate').value = '0'; + const document = { + querySelector(sel) { return el(sel.match(/data-role="([^"]+)"/)[1]); }, + createElement() { return makeEl(); }, + }; + let now = 1000000; + const intervals = []; + const posts = []; + const reply = r => Promise.resolve({ ok: r.code >= 200 && r.code < 300, status: r.code, json: () => Promise.resolve(r.body) }); + const fetch = (url, opts) => { + if (device.offline) return Promise.reject(new Error('offline')); + if (opts && opts.method === 'POST') { + posts.push({ url, body: opts.body ? JSON.parse(opts.body) : null }); + const r = url === '/api/v1/runs' ? device.post : device.abort; + if (r === 'hold') return new Promise(resolve => { device.release = r2 => resolve(reply(r2)); }); + return reply(r); + } + if (url === '/api/v1/status') return reply({ code: 200, body: JSON.parse(JSON.stringify(device.status)) }); + if (url === '/api/v1/workloads') return reply({ code: 200, body: { workloads: device.workloads } }); + return reply({ code: 404, body: {} }); + }; + const run = new Function('document', 'fetch', 'setInterval', 'setTimeout', 'clearTimeout', 'AbortController', 'Date', PAGE); + run(document, fetch, fn => intervals.push(fn), () => 0, () => {}, + class { constructor() { this.signal = {}; } abort() {} }, { now: () => now }); + const buttons = () => { + const out = {}; + el('run-workloads').children.forEach(row => row.children.forEach(c => { + if (c.dataset.workload) out[c.dataset.workload] = c; + })); + return out; + }; + const rows = () => el('run-workloads').children.map(row => row.children.map(c => c.textContent).join(' | ')); + return { + el, posts, buttons, rows, + async tick(ms) { now += ms; intervals[0](); intervals[1](); await flush(); }, + async wait(ms) { now += ms; intervals[1](); await flush(); }, + async click(id) { const b = buttons()[id]; if (b && !b.disabled) b.listeners.click(); await flush(); }, + async forceClick(id) { buttons()[id].listeners.click(); await flush(); }, + async abort() { const a = el('run-abort'); if (!a.hidden && !a.disabled) a.listeners.click(); await flush(); }, + snap() { + const b = buttons(); + const disabled = {}; + Object.keys(b).forEach(k => { disabled[k] = b[k].disabled; }); + return { + state: el('run-state').textContent, status: el('run-state').dataset.status, + workload: el('run-workload').textContent, id: el('run-id').textContent, + device: el('run-device').textContent, freshness: el('run-freshness').textContent, + message: el('run-message').textContent, abortHidden: el('run-abort').hidden, + disabled, posts: posts.length, + }; + }, + }; +} +const WORKLOADS = [ + { id: 'BOOT', status: 'available' }, { id: 'IDLE', status: 'available' }, + { id: 'WIFI_ASSOCIATED_IDLE', status: 'available' }, { id: 'NET_TX', status: 'available' }, + { id: 'NET_RX', status: 'available' }, { id: 'NET_BIDIRECTIONAL', status: 'available' }, + { id: 'CPU_STRESS', status: 'available' }, { id: 'FLASH_WRITE', status: 'available' }, + { id: 'NVS_WRITE', status: 'available' }, { id: 'OTA_PARTITION_WRITE', status: 'available' }, + { id: 'CONTROLLED_REBOOT', status: 'available' }, { id: 'FAN_PWM_HOLD', status: 'available' }, + { id: 'BLE_STRESS', status: 'unsupported' }, +]; +const st = (run_state, workload, run_id, result, extra) => + Object.assign({ run_state, workload, run_id, result, uptime_ms: 1 }, extra || {}); +const IDLE = st('idle', 'IDLE', '', 'none'); +function device(status) { return { status, workloads: WORKLOADS, post: null, abort: null, offline: false }; } + +(async () => { + const out = {}; + + { // 1, 2, 3, 10, 11: start, device-confirmed running, pass, duplicate protection, re-enable + const d = device(IDLE); const p = page(d); await p.tick(0); + const s = [p.snap()]; + d.post = 'hold'; + await p.click('CPU_STRESS'); s.push(p.snap()); + await p.forceClick('CPU_STRESS'); await p.click('NVS_WRITE'); s.push(p.snap()); + d.status = st('running', 'CPU_STRESS', 'r1', 'none'); + d.release({ code: 201, body: { run_id: 'r1', state: 'running' } }); await flush(); s.push(p.snap()); + await p.forceClick('NVS_WRITE'); s.push(p.snap()); + d.status = st('complete', 'CPU_STRESS', 'r1', 'pass'); + await p.tick(2000); s.push(p.snap()); + await p.tick(2000); await p.tick(2000); await p.tick(2000); s.push(p.snap()); + out.start_pass = { snaps: s, body: p.posts[0].body }; + } + { // POST acknowledged but the device does not (yet) report the run: never RUNNING + const d = device(IDLE); const p = page(d); await p.tick(0); + d.post = { code: 201, body: { run_id: 'r2', state: 'running' } }; + await p.click('IDLE'); + out.ack_only = p.snap(); + } + { // 4: fail + const d = device(st('running', 'FLASH_WRITE', 'r3', 'none')); const p = page(d); await p.tick(0); + const a = p.snap(); + d.status = st('complete', 'FLASH_WRITE', 'r3', 'fail'); await p.tick(2000); + out.fail = [a, p.snap()]; + } + { // 5: abort + const d = device(st('running', 'IDLE', 'r4', 'none')); const p = page(d); await p.tick(0); + const s = [p.snap()]; + d.abort = 'hold'; + await p.abort(); s.push(p.snap()); + d.status = st('aborting', 'IDLE', 'r4', 'none'); + d.release({ code: 200, body: { run_id: 'r4', state: 'aborting' } }); await flush(); s.push(p.snap()); + d.status = st('complete', 'IDLE', 'r4', 'aborted'); await p.tick(2000); s.push(p.snap()); + out.abort = { snaps: s, url: p.posts[0].url }; + } + { // 6, 7: device rejections + const d = device(IDLE); const p = page(d); await p.tick(0); + d.post = { code: 400, body: { error: 'duration_ms must be 1..3600000' } }; + await p.click('NVS_WRITE'); const a = p.snap(); + d.post = { code: 409, body: { error: 'run already active' } }; + await p.click('NVS_WRITE'); const b = p.snap(); + out.rejected = [a, b]; + } + { // browser-side completeness checks never POST + const d = device(IDLE); const p = page(d); await p.tick(0); + await p.click('NET_TX'); const a = p.snap(); + p.el('run-host').value = '192.168.4.2'; p.el('run-port').value = '99999'; + await p.click('NET_TX'); const b = p.snap(); + p.el('run-duration').value = '0'; await p.click('IDLE'); const c = p.snap(); + p.el('run-duration').value = '2.5'; p.el('run-port').value = '5201'; p.el('run-rate').value = '800000'; + d.post = { code: 201, body: { run_id: 'r5', state: 'running' } }; + await p.click('NET_TX'); + out.validation = { snaps: [a, b, c], body: p.posts[0].body, posts: p.posts.length }; + } + { // 8, 9, 11: loss of contact while running, then reconnect into the still-active run + const d = device(st('running', 'NET_RX', 'r6', 'none')); const p = page(d); await p.tick(0); + const s = [p.snap()]; + d.offline = true; + await p.tick(2000); s.push(p.snap()); + await p.tick(4000); s.push(p.snap()); + await p.tick(10000); s.push(p.snap()); + d.offline = false; await p.tick(2000); s.push(p.snap()); + d.status = st('complete', 'NET_RX', 'r6', 'pass'); d.offline = true; + await p.tick(6000); s.push(p.snap()); + d.offline = false; d.status = IDLE; await p.tick(2000); s.push(p.snap()); + out.loss = s; + } + { // 9: a fresh page opened while a run is already active + const d = device(st('running', 'OTA_PARTITION_WRITE', 'r7', 'none')); const p = page(d); await p.tick(0); + out.fresh_active = p.snap(); + } + { // never reached the device + const d = device(IDLE); d.offline = true; const p = page(d); await p.tick(0); + out.unreachable = p.snap(); + } + { // 12: workloads this page cannot build a request for + const d = device(IDLE); const p = page(d); await p.tick(0); + out.unknown = { rows: p.rows(), buttons: Object.keys(p.buttons()) }; + } + { // 13: CONTROLLED_REBOOT disconnect and return + const d = device(IDLE); const p = page(d); await p.tick(0); + d.status = st('running', 'CONTROLLED_REBOOT', 'r9', 'none'); + d.post = { code: 201, body: { run_id: 'r9', state: 'running' } }; + await p.click('CONTROLLED_REBOOT'); const s = [p.snap()]; + d.offline = true; await p.tick(6000); s.push(p.snap()); + await p.tick(10000); s.push(p.snap()); + d.offline = false; d.status = st('idle', 'IDLE', '', 'none', { previous_reboot_run_id: 'r9' }); + await p.tick(2000); s.push(p.snap()); + out.reboot = { snaps: s, rows: p.rows() }; + } + process.stdout.write(JSON.stringify(out)); +})().catch(e => { console.error(e && e.stack || e); process.exit(1); }); +""" + +TIMED = ["BOOT", "IDLE", "WIFI_ASSOCIATED_IDLE", "CPU_STRESS", "FLASH_WRITE", "NVS_WRITE", + "OTA_PARTITION_WRITE", "CONTROLLED_REBOOT"] +NETWORK = ["NET_TX", "NET_RX", "NET_BIDIRECTIONAL"] + + +@unittest.skipUnless(shutil.which("node") or os.environ.get("DRAGONBENCH_REQUIRE_NODE"), + "needs Node.js to run the /run page script") +class RunPageBehaviourTests(unittest.TestCase): + """What the operator sees on /run, driven only by scripted device responses.""" + + @classmethod + def setUpClass(cls): + html = _firmware_page("run_page") + script = html[html.index("")] + with tempfile.TemporaryDirectory() as tmp: + harness = Path(tmp) / "harness.js" + harness.write_text(HARNESS % json.dumps(script), encoding="utf-8") + result = subprocess.run(["node", str(harness)], capture_output=True, text=True, + encoding="utf-8", timeout=30) + if result.returncode != 0: + raise AssertionError(result.stderr) + cls.out = json.loads(result.stdout) + + def assertButtons(self, snap, disabled): + self.assertTrue(snap["disabled"], snap) + for workload, value in snap["disabled"].items(): + self.assertEqual(value, disabled, (workload, snap)) + + def test_idle_click_shows_starting_only_while_the_request_is_in_flight(self): + ready, starting = self.out["start_pass"]["snaps"][:2] + self.assertEqual(ready["state"], "READY") + self.assertButtons(ready, False) + self.assertEqual(starting["status"], "starting") + self.assertTrue(starting["state"].startswith("STARTING"), starting) + self.assertIn("CPU_STRESS", starting["state"]) + self.assertButtons(starting, True) + self.assertTrue(starting["abortHidden"]) + + def test_start_request_carries_only_the_known_fields(self): + self.assertEqual(self.out["start_pass"]["body"], {"workload": "CPU_STRESS", "duration_ms": 30000}) + + def test_running_comes_only_from_device_status(self): + running = self.out["start_pass"]["snaps"][3] + self.assertEqual(running["status"], "running") + self.assertTrue(running["state"].startswith("RUNNING"), running) + self.assertIn("CPU_STRESS", running["state"]) + self.assertEqual(running["id"], "r1") + self.assertFalse(running["abortHidden"]) + self.assertButtons(running, True) + self.assertEqual(running["message"], "") + + def test_post_acknowledgement_alone_is_not_running(self): + snap = self.out["ack_only"] + self.assertEqual(snap["state"], "READY") + self.assertNotEqual(snap["status"], "running") + self.assertIn("r2", snap["message"]) + self.assertIn("waiting for the device", snap["message"]) + + def test_duplicate_clicks_send_one_request(self): + snaps = self.out["start_pass"]["snaps"] + self.assertEqual(snaps[2]["posts"], 1) + self.assertEqual(snaps[4]["posts"], 1) + + def test_pass_is_shown_and_persists_while_the_device_reports_it(self): + passed, later = self.out["start_pass"]["snaps"][5:7] + for snap in (passed, later): + self.assertEqual(snap["status"], "pass") + self.assertTrue(snap["state"].startswith("PASS"), snap) + self.assertIn("CPU_STRESS", snap["state"]) + self.assertEqual(snap["id"], "r1") + self.assertButtons(snap, False) + self.assertTrue(snap["abortHidden"]) + + def test_fail_is_shown(self): + running, failed = self.out["fail"] + self.assertEqual(running["status"], "running") + self.assertEqual(failed["status"], "fail") + self.assertTrue(failed["state"].startswith("FAIL"), failed) + self.assertIn("FLASH_WRITE", failed["state"]) + self.assertButtons(failed, False) + + def test_abort_is_driven_by_device_state(self): + running, requested, aborting, aborted = self.out["abort"]["snaps"] + self.assertEqual(self.out["abort"]["url"], "/api/v1/runs/r4/abort") + self.assertFalse(running["abortHidden"]) + self.assertEqual(requested["status"], "running") + self.assertEqual(aborting["status"], "aborting") + self.assertTrue(aborting["state"].startswith("ABORTING"), aborting) + self.assertTrue(aborting["abortHidden"]) + self.assertButtons(aborting, True) + self.assertEqual(aborted["status"], "aborted") + self.assertTrue(aborted["state"].startswith("ABORTED"), aborted) + self.assertButtons(aborted, False) + + def test_device_validation_rejection_is_shown(self): + snap = self.out["rejected"][0] + self.assertIn("HTTP 400", snap["message"]) + self.assertIn("duration_ms must be 1..3600000", snap["message"]) + self.assertEqual(snap["state"], "READY") + self.assertButtons(snap, False) + + def test_device_conflict_rejection_is_shown(self): + snap = self.out["rejected"][1] + self.assertIn("HTTP 409", snap["message"]) + self.assertIn("run already active", snap["message"]) + self.assertEqual(snap["state"], "READY") + + def test_incomplete_input_is_caught_before_posting(self): + v = self.out["validation"] + self.assertIn("host", v["snaps"][0]["message"]) + self.assertIn("port", v["snaps"][1]["message"]) + self.assertIn("Duration", v["snaps"][2]["message"]) + self.assertEqual([s["posts"] for s in v["snaps"]], [0, 0, 0]) + self.assertEqual(v["posts"], 1) + self.assertEqual(v["body"], {"workload": "NET_TX", "duration_ms": 2500, + "host": "192.168.4.2", "port": 5201, "rate_bps": 800000}) + + def test_lost_contact_never_leaves_running_painted(self): + live, recent, stale, lost, back, stale_pass, ready = self.out["loss"] + self.assertEqual(live["status"], "running") + self.assertEqual(recent["status"], "running") + self.assertEqual(stale["state"], "STALE") + self.assertEqual(lost["state"], "DISCONNECTED") + for snap in (stale, lost): + self.assertTrue(snap["abortHidden"]) + self.assertButtons(snap, True) + self.assertIn("last report", snap["freshness"]) + self.assertEqual(back["status"], "running") + self.assertIn("NET_RX", back["state"]) + self.assertEqual(back["id"], "r6") + + def test_controls_reenable_only_on_fresh_device_state(self): + stale_pass, ready = self.out["loss"][5:7] + self.assertEqual(stale_pass["state"], "STALE") + self.assertButtons(stale_pass, True) + self.assertEqual(ready["state"], "READY") + self.assertButtons(ready, False) + + def test_page_opened_during_a_run_shows_it(self): + snap = self.out["fresh_active"] + self.assertEqual(snap["status"], "running") + self.assertIn("OTA_PARTITION_WRITE", snap["state"]) + self.assertEqual(snap["id"], "r7") + self.assertButtons(snap, True) + self.assertFalse(snap["abortHidden"]) + + def test_unreachable_device_is_disconnected(self): + snap = self.out["unreachable"] + self.assertEqual(snap["state"], "DISCONNECTED") + self.assertEqual(snap["disabled"], {}) + + def test_only_workloads_with_a_known_request_get_a_start_button(self): + self.assertEqual(sorted(self.out["unknown"]["buttons"]), sorted(TIMED + NETWORK)) + rows = self.out["unknown"]["rows"] + fan = next(r for r in rows if r.startswith("FAN_PWM_HOLD")) + self.assertIn("no controls on this page yet", fan) + ble = next(r for r in rows if r.startswith("BLE_STRESS")) + self.assertIn("not supported", ble) + + def test_controlled_reboot_disconnect_is_not_a_failure(self): + running, stale, lost, back = self.out["reboot"]["snaps"] + self.assertIn("CONTROLLED_REBOOT", running["state"]) + self.assertEqual(stale["state"], "STALE") + self.assertEqual(lost["state"], "DISCONNECTED") + for snap in (stale, lost): + self.assertNotIn("FAIL", snap["state"]) + self.assertEqual(back["state"], "READY") + self.assertIn("rebooted by run r9", back["device"]) + self.assertButtons(back, False) + reboot_row = next(r for r in self.out["reboot"]["rows"] if r.startswith("CONTROLLED_REBOOT")) + self.assertIn("reboots the device", reboot_row) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/index.html b/web/index.html index 7993390..c5833e8 100644 --- a/web/index.html +++ b/web/index.html @@ -62,6 +62,8 @@ details{border-top:1px solid var(--border);padding-top:8px;margin-top:8px} summary{cursor:pointer;color:var(--muted-fg);font-size:.78rem;width:fit-content;font-family:ui-monospace,SFMono-Regular,Consolas,monospace} pre{margin:8px 0 0;max-height:320px;overflow:auto;background:var(--code);border:1px solid var(--border);border-radius:var(--radius-sm);padding:10px;font:.72rem/1.5 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre} +a{color:var(--primary)} +.panel-link{margin:8px 0 0} footer{padding:16px 0 24px;color:var(--muted-fg);text-align:center;font-size:.76rem} @media (max-width:900px){.metric-grid{grid-template-columns:repeat(2,minmax(0,1fr))}} @media (max-width:620px){.app-header{flex-direction:column;align-items:start}.api-status{justify-items:start}.metric-grid{grid-template-columns:1fr}.sensor-row{flex-direction:column;align-items:start}} @@ -124,6 +126,7 @@

Workloads

+
Raw /api/v1/workloads
unavailable
From b2d3299f7fcb8816301e21c805b91988627ad270 Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Wed, 30 Sep 2026 11:03:17 +0000 Subject: [PATCH 2/4] web: time out /run start and abort requests The start and abort POSTs had no timeout. A request that stalled kept STARTING on screen with every Start button disabled, or kept Abort disabled, until the page was reloaded, even after polling had marked the device STALE or DISCONNECTED. Both requests now use the same AbortController pattern and 4 s limit as the status poll. When the limit is reached the request is aborted, the page reports that no response arrived, clears STARTING or the pending abort, and polls at once. A timeout is not a FAIL or an ABORTED: what the device reports next decides what is shown. The test harness now has timers that fire and an AbortController that rejects a held request. New tests cover a start and an abort that never answer, including an abort that reached the device, and check that answered requests clear their timer without aborting. The contract test no longer depends on the abort request's options ending after method. --- firmware/targets/esp32s3/main/main.c | 10 +- tests/test_contract.py | 2 +- tests/test_run_page_js.py | 139 ++++++++++++++++++++++++++- 3 files changed, 141 insertions(+), 10 deletions(-) diff --git a/firmware/targets/esp32s3/main/main.c b/firmware/targets/esp32s3/main/main.c index e24716e..c7105e0 100644 --- a/firmware/targets/esp32s3/main/main.c +++ b/firmware/targets/esp32s3/main/main.c @@ -1085,23 +1085,25 @@ static const char run_page[] = " var req=request(id,kind);" " if(req.error){say(req.error);return}" " starting=id;accepted=null;say('');render();" -" fetch('/api/v1/runs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(req.body)})" +" var ctl=new AbortController(),timer=setTimeout(function(){ctl.abort()},4000);" +" fetch('/api/v1/runs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(req.body),signal:ctl.signal})" " .then(function(res){return res.json().then(function(data){return {code:res.status,data:data}},function(){return {code:res.status,data:{}}})})" " .then(function(r){" " if(r.code===201&&r.data.run_id){accepted=r.data.run_id;say('Run '+accepted+' accepted; waiting for the device to report it.')}" " else say('Refused by the device (HTTP '+r.code+'): '+(r.data.error||'no reason given'));" " })" " .catch(function(){say('No response to the start request. The state above comes from the device; check it before retrying.')})" -" .then(function(){starting=null;render();poll()});" +" .then(function(){clearTimeout(timer);starting=null;render();poll()});" " }" " role('run-abort').addEventListener('click',function(){" " if(aborting||!status||status.run_state!=='running')return;" " var id=status.run_id;aborting=true;render();" -" fetch('/api/v1/runs/'+encodeURIComponent(id)+'/abort',{method:'POST'})" +" var ctl=new AbortController(),timer=setTimeout(function(){ctl.abort()},4000);" +" fetch('/api/v1/runs/'+encodeURIComponent(id)+'/abort',{method:'POST',signal:ctl.signal})" " .then(function(res){return res.json().then(function(data){return {ok:res.ok,code:res.status,data:data}},function(){return {ok:res.ok,code:res.status,data:{}}})})" " .then(function(r){say(r.ok?'Abort requested for run '+id+'.':'Abort refused (HTTP '+r.code+'): '+(r.data.error||'no reason given'))})" " .catch(function(){say('No response to the abort request.')})" -" .then(function(){aborting=false;render();poll()});" +" .then(function(){clearTimeout(timer);aborting=false;render();poll()});" " });" " function getJson(path,signal){" " return fetch(path,{cache:'no-store',signal:signal}).then(function(res){" diff --git a/tests/test_contract.py b/tests/test_contract.py index 739d400..bd82ee2 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -148,7 +148,7 @@ def test_run_page_has_no_external_dependency_or_innerhtml(self): def test_run_page_writes_only_through_the_run_api(self): page = _firmware_page("run_page") self.assertIn("fetch('/api/v1/runs',{method:'POST'", page) - self.assertIn("'/abort',{method:'POST'}", page) + self.assertIn("'/abort',{method:'POST'", page) self.assertEqual(page.count("method:'POST'"), 2) self.assertNotIn("/api/v1/network", page) diff --git a/tests/test_run_page_js.py b/tests/test_run_page_js.py index 2b4777e..0ff3c6a 100644 --- a/tests/test_run_page_js.py +++ b/tests/test_run_page_js.py @@ -35,13 +35,30 @@ let now = 1000000; const intervals = []; const posts = []; + // Timers only fire from elapse(), so the existing scenarios, which never call + // it, behave exactly as before. A timed-out signal rejects a held request the + // way a browser fetch does. + const timers = []; + let statusGets = 0; + class FakeAbortController { + constructor() { this.signal = { aborted: false, listeners: [], addEventListener(t, fn) { this.listeners.push(fn); } }; } + abort() { if (this.signal.aborted) return; this.signal.aborted = true; this.signal.listeners.forEach(fn => fn()); } + } const reply = r => Promise.resolve({ ok: r.code >= 200 && r.code < 300, status: r.code, json: () => Promise.resolve(r.body) }); const fetch = (url, opts) => { + if (url === '/api/v1/status') statusGets++; if (device.offline) return Promise.reject(new Error('offline')); if (opts && opts.method === 'POST') { - posts.push({ url, body: opts.body ? JSON.parse(opts.body) : null }); + const post = { url, body: opts.body ? JSON.parse(opts.body) : null, signalled: !!opts.signal, aborted: false }; + posts.push(post); const r = url === '/api/v1/runs' ? device.post : device.abort; - if (r === 'hold') return new Promise(resolve => { device.release = r2 => resolve(reply(r2)); }); + if (r === 'hold') return new Promise((resolve, reject) => { + device.release = r2 => resolve(reply(r2)); + if (opts.signal) opts.signal.addEventListener('abort', () => { + post.aborted = true; + reject(Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })); + }); + }); return reply(r); } if (url === '/api/v1/status') return reply({ code: 200, body: JSON.parse(JSON.stringify(device.status)) }); @@ -49,8 +66,10 @@ return reply({ code: 404, body: {} }); }; const run = new Function('document', 'fetch', 'setInterval', 'setTimeout', 'clearTimeout', 'AbortController', 'Date', PAGE); - run(document, fetch, fn => intervals.push(fn), () => 0, () => {}, - class { constructor() { this.signal = {}; } abort() {} }, { now: () => now }); + run(document, fetch, fn => intervals.push(fn), + (fn, ms) => timers.push({ fn, ms, due: now + ms, cleared: false }), + handle => { if (timers[handle - 1]) timers[handle - 1].cleared = true; }, + FakeAbortController, { now: () => now }); const buttons = () => { const out = {}; el('run-workloads').children.forEach(row => row.children.forEach(c => { @@ -66,6 +85,14 @@ class { constructor() { this.signal = {}; } abort() {} }, { now: () => now }); async click(id) { const b = buttons()[id]; if (b && !b.disabled) b.listeners.click(); await flush(); }, async forceClick(id) { buttons()[id].listeners.click(); await flush(); }, async abort() { const a = el('run-abort'); if (!a.hidden && !a.disabled) a.listeners.click(); await flush(); }, + // Let wall-clock time pass: due timers fire and the display refreshes, but the + // 2 s poll interval does not run, so any status request seen afterwards was + // made by the page itself. + async elapse(ms) { + now += ms; + timers.filter(t => !t.cleared && t.due <= now).forEach(t => { t.cleared = true; t.fn(); }); + intervals[1](); await flush(); + }, snap() { const b = buttons(); const disabled = {}; @@ -75,7 +102,9 @@ class { constructor() { this.signal = {}; } abort() {} }, { now: () => now }); workload: el('run-workload').textContent, id: el('run-id').textContent, device: el('run-device').textContent, freshness: el('run-freshness').textContent, message: el('run-message').textContent, abortHidden: el('run-abort').hidden, - disabled, posts: posts.length, + disabled, posts: posts.length, abortDisabled: el('run-abort').disabled, + abortedPosts: posts.filter(x => x.aborted).length, + pendingTimers: timers.filter(t => !t.cleared).map(t => t.ms), statusGets, }; }, }; @@ -188,6 +217,36 @@ class { constructor() { this.signal = {}; } abort() {} }, { now: () => now }); await p.tick(2000); s.push(p.snap()); out.reboot = { snaps: s, rows: p.rows() }; } + { // start request that never answers: the timeout ends STARTING and device state takes over + const d = device(IDLE); const p = page(d); await p.tick(0); + d.post = 'hold'; + await p.click('CPU_STRESS'); const s = [p.snap()]; + d.offline = true; + await p.elapse(3999); s.push(p.snap()); + await p.elapse(1); s.push(p.snap()); + await p.tick(2000); s.push(p.snap()); + await p.tick(10000); s.push(p.snap()); + d.offline = false; d.status = st('running', 'CPU_STRESS', 'r10', 'none'); + await p.tick(2000); s.push(p.snap()); + out.start_timeout = { snaps: s, post: p.posts[0] }; + } + { // abort request that never answers while the device still reports the run + const d = device(st('running', 'IDLE', 'r11', 'none')); const p = page(d); await p.tick(0); + d.abort = 'hold'; + await p.abort(); const s = [p.snap()]; + await p.elapse(4000); s.push(p.snap()); + d.abort = { code: 200, body: { run_id: 'r11', state: 'aborting' } }; + await p.abort(); s.push(p.snap()); + out.abort_timeout_running = { snaps: s, posts: p.posts }; + } + { // abort request that reached the device but whose answer never came back + const d = device(st('running', 'IDLE', 'r12', 'none')); const p = page(d); await p.tick(0); + d.abort = 'hold'; + await p.abort(); const s = [p.snap()]; + d.status = st('aborting', 'IDLE', 'r12', 'none'); + await p.elapse(4000); s.push(p.snap()); + out.abort_timeout_landed = s; + } process.stdout.write(JSON.stringify(out)); })().catch(e => { console.error(e && e.stack || e); process.exit(1); }); """ @@ -364,6 +423,76 @@ def test_controlled_reboot_disconnect_is_not_a_failure(self): reboot_row = next(r for r in self.out["reboot"]["rows"] if r.startswith("CONTROLLED_REBOOT")) self.assertIn("reboots the device", reboot_row) + def test_start_request_that_never_answers_times_out_into_device_state(self): + t = self.out["start_timeout"] + starting, just_before, timed_out, stale, lost, back = t["snaps"] + self.assertTrue(t["post"]["signalled"]) + self.assertEqual(starting["status"], "starting") + self.assertEqual(starting["pendingTimers"], [4000]) + self.assertButtons(starting, True) + # Still in flight one millisecond before the timeout. + self.assertEqual(just_before["status"], "starting") + self.assertEqual(just_before["abortedPosts"], 0) + # The timeout aborts the request, clears STARTING, and polls at once. + self.assertTrue(t["post"]["aborted"]) + self.assertEqual(timed_out["abortedPosts"], 1) + self.assertNotEqual(timed_out["status"], "starting") + self.assertNotIn("FAIL", timed_out["state"]) + self.assertIn("No response to the start request", timed_out["message"]) + self.assertGreater(timed_out["statusGets"], just_before["statusGets"]) + self.assertEqual(timed_out["posts"], 1) + # From here on only the device's own reports decide what is shown. + self.assertEqual(stale["state"], "STALE") + self.assertButtons(stale, True) + self.assertEqual(lost["state"], "DISCONNECTED") + self.assertButtons(lost, True) + self.assertEqual(back["status"], "running") + self.assertIn("CPU_STRESS", back["state"]) + self.assertEqual(back["id"], "r10") + self.assertFalse(back["abortHidden"]) + + def test_abort_request_that_never_answers_times_out_and_can_be_retried(self): + t = self.out["abort_timeout_running"] + requested, timed_out, retried = t["snaps"] + self.assertTrue(t["posts"][0]["signalled"]) + self.assertFalse(requested["abortHidden"]) + self.assertTrue(requested["abortDisabled"]) + self.assertEqual(requested["pendingTimers"], [4000]) + self.assertTrue(t["posts"][0]["aborted"]) + self.assertEqual(timed_out["abortedPosts"], 1) + self.assertIn("No response to the abort request", timed_out["message"]) + self.assertGreater(timed_out["statusGets"], requested["statusGets"]) + # The device still reports the run, so it stays RUNNING and Abort is back. + self.assertEqual(timed_out["status"], "running") + self.assertFalse(timed_out["abortHidden"]) + self.assertFalse(timed_out["abortDisabled"]) + self.assertEqual(retried["posts"], 2) + self.assertEqual(t["posts"][1]["url"], "/api/v1/runs/r11/abort") + self.assertIn("Abort requested for run r11", retried["message"]) + + def test_abort_timeout_defers_to_device_reported_state(self): + requested, timed_out = self.out["abort_timeout_landed"] + self.assertTrue(requested["abortDisabled"]) + self.assertEqual(timed_out["abortedPosts"], 1) + self.assertEqual(timed_out["status"], "aborting") + self.assertTrue(timed_out["state"].startswith("ABORTING"), timed_out) + self.assertNotEqual(timed_out["status"], "aborted") + self.assertTrue(timed_out["abortHidden"]) + self.assertFalse(timed_out["abortDisabled"]) + + def test_answered_requests_clear_their_timeout_without_aborting(self): + start = self.out["start_pass"] + self.assertEqual(start["snaps"][1]["pendingTimers"], [4000]) + for snap in start["snaps"][3:]: + self.assertEqual(snap["pendingTimers"], [], snap) + self.assertEqual(snap["abortedPosts"], 0, snap) + abort = self.out["abort"]["snaps"] + self.assertEqual(abort[1]["pendingTimers"], [4000]) + self.assertTrue(abort[1]["abortDisabled"]) + for snap in abort[2:]: + self.assertEqual(snap["pendingTimers"], [], snap) + self.assertEqual(snap["abortedPosts"], 0, snap) + if __name__ == "__main__": unittest.main() From d8c0f3c478c90f1e02f7993959e8e7b2e56eab9e Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Wed, 30 Sep 2026 11:17:00 +0000 Subject: [PATCH 3/4] web: keep /run timeouts from reading as device replies When a start or abort reply's headers arrived but its body stalled, the 4 s timeout aborted the body read and the JSON fallback turned that into an empty payload. A start then read "Refused by the device (HTTP 201)" although the device had accepted it, and an abort read "Abort requested" instead of reporting no response. Both body-read fallbacks now rethrow when their request's own signal was aborted, so a timeout takes the existing no-response path. A complete reply whose body is not JSON still falls back to an empty payload and is reported with its HTTP status. Device-reported state still decides the indicator. New tests stall the body after the headers for a start and an abort, and check that a non-JSON reply is still reported with its status. --- firmware/targets/esp32s3/main/main.c | 4 +- tests/test_run_page_js.py | 73 ++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/firmware/targets/esp32s3/main/main.c b/firmware/targets/esp32s3/main/main.c index c7105e0..c496b03 100644 --- a/firmware/targets/esp32s3/main/main.c +++ b/firmware/targets/esp32s3/main/main.c @@ -1087,7 +1087,7 @@ static const char run_page[] = " starting=id;accepted=null;say('');render();" " var ctl=new AbortController(),timer=setTimeout(function(){ctl.abort()},4000);" " fetch('/api/v1/runs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(req.body),signal:ctl.signal})" -" .then(function(res){return res.json().then(function(data){return {code:res.status,data:data}},function(){return {code:res.status,data:{}}})})" +" .then(function(res){return res.json().then(function(data){return {code:res.status,data:data}},function(err){if(ctl.signal.aborted)throw err;return {code:res.status,data:{}}})})" " .then(function(r){" " if(r.code===201&&r.data.run_id){accepted=r.data.run_id;say('Run '+accepted+' accepted; waiting for the device to report it.')}" " else say('Refused by the device (HTTP '+r.code+'): '+(r.data.error||'no reason given'));" @@ -1100,7 +1100,7 @@ static const char run_page[] = " var id=status.run_id;aborting=true;render();" " var ctl=new AbortController(),timer=setTimeout(function(){ctl.abort()},4000);" " fetch('/api/v1/runs/'+encodeURIComponent(id)+'/abort',{method:'POST',signal:ctl.signal})" -" .then(function(res){return res.json().then(function(data){return {ok:res.ok,code:res.status,data:data}},function(){return {ok:res.ok,code:res.status,data:{}}})})" +" .then(function(res){return res.json().then(function(data){return {ok:res.ok,code:res.status,data:data}},function(err){if(ctl.signal.aborted)throw err;return {ok:res.ok,code:res.status,data:{}}})})" " .then(function(r){say(r.ok?'Abort requested for run '+id+'.':'Abort refused (HTTP '+r.code+'): '+(r.data.error||'no reason given'))})" " .catch(function(){say('No response to the abort request.')})" " .then(function(){clearTimeout(timer);aborting=false;render();poll()});" diff --git a/tests/test_run_page_js.py b/tests/test_run_page_js.py index 0ff3c6a..1f73eef 100644 --- a/tests/test_run_page_js.py +++ b/tests/test_run_page_js.py @@ -52,6 +52,19 @@ class FakeAbortController { const post = { url, body: opts.body ? JSON.parse(opts.body) : null, signalled: !!opts.signal, aborted: false }; posts.push(post); const r = url === '/api/v1/runs' ? device.post : device.abort; + // Headers arrive at once but the body never finishes; aborting the signal + // errors the body read, as it does in a browser. + if (r && r.stallBody) return Promise.resolve({ ok: r.code >= 200 && r.code < 300, status: r.code, + json: () => new Promise((resolve, reject) => { + post.bodyStarted = true; + opts.signal.addEventListener('abort', () => { + post.aborted = true; + reject(Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })); + }); + }) }); + // A complete response whose body is not JSON. + if (r && r.badJson) return Promise.resolve({ ok: r.code >= 200 && r.code < 300, status: r.code, + json: () => Promise.reject(new SyntaxError('Unexpected end of JSON input')) }); if (r === 'hold') return new Promise((resolve, reject) => { device.release = r2 => resolve(reply(r2)); if (opts.signal) opts.signal.addEventListener('abort', () => { @@ -247,6 +260,31 @@ class FakeAbortController { await p.elapse(4000); s.push(p.snap()); out.abort_timeout_landed = s; } + { // start accepted (201 headers) but the body stalls until the timeout + const d = device(IDLE); const p = page(d); await p.tick(0); + d.post = { code: 201, stallBody: true }; + await p.click('CPU_STRESS'); const s = [p.snap()]; + d.status = st('running', 'CPU_STRESS', 'r13', 'none'); + await p.elapse(4000); s.push(p.snap()); + out.start_body_stall = { snaps: s, post: p.posts[0] }; + } + { // abort answered with 200 headers but the body stalls until the timeout + const d = device(st('running', 'IDLE', 'r14', 'none')); const p = page(d); await p.tick(0); + d.abort = { code: 200, stallBody: true }; + await p.abort(); const s = [p.snap()]; + d.status = st('aborting', 'IDLE', 'r14', 'none'); + await p.elapse(4000); s.push(p.snap()); + out.abort_body_stall = { snaps: s, post: p.posts[0] }; + } + { // a complete response that is not JSON still falls back to an empty payload + const d = device(IDLE); const p = page(d); await p.tick(0); + d.post = { code: 409, badJson: true }; + await p.click('NVS_WRITE'); const a = p.snap(); + d.status = st('running', 'IDLE', 'r15', 'none'); await p.tick(2000); + d.abort = { code: 409, badJson: true }; + await p.abort(); const b = p.snap(); + out.bad_json = [a, b]; + } process.stdout.write(JSON.stringify(out)); })().catch(e => { console.error(e && e.stack || e); process.exit(1); }); """ @@ -493,6 +531,41 @@ def test_answered_requests_clear_their_timeout_without_aborting(self): self.assertEqual(snap["pendingTimers"], [], snap) self.assertEqual(snap["abortedPosts"], 0, snap) + def test_start_body_stall_times_out_instead_of_reading_as_refused(self): + t = self.out["start_body_stall"] + starting, timed_out = t["snaps"] + self.assertTrue(t["post"]["bodyStarted"]) + self.assertEqual(starting["status"], "starting") + self.assertTrue(t["post"]["aborted"]) + self.assertIn("No response to the start request", timed_out["message"]) + self.assertNotIn("Refused", timed_out["message"]) + self.assertNotIn("HTTP 201", timed_out["message"]) + self.assertNotEqual(timed_out["status"], "starting") + self.assertGreater(timed_out["statusGets"], starting["statusGets"]) + self.assertEqual(timed_out["status"], "running") + self.assertEqual(timed_out["id"], "r13") + + def test_abort_body_stall_times_out_instead_of_reading_as_requested(self): + t = self.out["abort_body_stall"] + requested, timed_out = t["snaps"] + self.assertTrue(t["post"]["bodyStarted"]) + self.assertTrue(requested["abortDisabled"]) + self.assertTrue(t["post"]["aborted"]) + self.assertIn("No response to the abort request", timed_out["message"]) + self.assertNotIn("Abort requested", timed_out["message"]) + self.assertFalse(timed_out["abortDisabled"]) + self.assertGreater(timed_out["statusGets"], requested["statusGets"]) + self.assertEqual(timed_out["status"], "aborting") + self.assertNotEqual(timed_out["status"], "aborted") + self.assertTrue(timed_out["abortHidden"]) + + def test_non_json_reply_still_reports_the_http_status(self): + start, abort = self.out["bad_json"] + self.assertEqual(start["message"], "Refused by the device (HTTP 409): no reason given") + self.assertEqual(start["state"], "READY") + self.assertEqual(abort["message"], "Abort refused (HTTP 409): no reason given") + self.assertEqual(abort["abortedPosts"], 0) + if __name__ == "__main__": unittest.main() From 8f99a0b2075cf0b9ab1e5ac805b5d187cdfa30cd Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Wed, 30 Sep 2026 11:34:54 +0000 Subject: [PATCH 4/4] web: keep the HTTP status when a /run reply body is lost A start or abort whose headers arrived but whose body did not (the 4 s timeout aborted it, or the connection dropped) was reported as "No response". The device had answered: a 201 means it accepted the start, and a 200 or 409 on abort is a real outcome. The body-read fallback now keeps the status and marks the body as lost for any failure other than a SyntaxError. A body that arrived but is not JSON keeps the empty-payload fallback as before. The page then says: - start 201: acknowledged, the run ID is unknown - start refusal: refused with the status, reason unavailable - abort 2xx: acknowledged for the run - abort refusal: refused with the status, reason unavailable "No response" is kept for failures before any headers arrive. No run ID is invented, and RUNNING, ABORTING and ABORTED still come only from /api/v1/status, which is polled straight after. Tests cover a stalled body after 201, 200 and 409 replies, and a connection drop during a 201 body. --- firmware/targets/esp32s3/main/main.c | 18 +++++-- tests/test_run_page_js.py | 79 +++++++++++++++++++++++++--- 2 files changed, 85 insertions(+), 12 deletions(-) diff --git a/firmware/targets/esp32s3/main/main.c b/firmware/targets/esp32s3/main/main.c index c496b03..f5c2b1a 100644 --- a/firmware/targets/esp32s3/main/main.c +++ b/firmware/targets/esp32s3/main/main.c @@ -1080,6 +1080,10 @@ static const char run_page[] = " }" " return {body:body};" " }" +" /* The headers, and so the HTTP status, arrived but the body did not: the" +" request timeout aborted it or the connection dropped. A body that arrived" +" but is not JSON is a SyntaxError and keeps the empty-payload fallback. */" +" function bodyLost(err){return !(err&&err.name==='SyntaxError')}" " function start(id,kind){" " if(starting||!canStart(connection()))return;" " var req=request(id,kind);" @@ -1087,9 +1091,11 @@ static const char run_page[] = " starting=id;accepted=null;say('');render();" " var ctl=new AbortController(),timer=setTimeout(function(){ctl.abort()},4000);" " fetch('/api/v1/runs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(req.body),signal:ctl.signal})" -" .then(function(res){return res.json().then(function(data){return {code:res.status,data:data}},function(err){if(ctl.signal.aborted)throw err;return {code:res.status,data:{}}})})" +" .then(function(res){return res.json().then(function(data){return {code:res.status,data:data}},function(err){return {code:res.status,data:{},lost:bodyLost(err)}})})" " .then(function(r){" -" if(r.code===201&&r.data.run_id){accepted=r.data.run_id;say('Run '+accepted+' accepted; waiting for the device to report it.')}" +" if(r.lost&&r.code===201)say('Start acknowledged by the device (HTTP 201), but its reply did not arrive, so the run ID is unknown. The state above comes from the device.');" +" else if(r.lost)say('Refused by the device (HTTP '+r.code+'); its reply did not arrive, so the reason is unavailable.');" +" else if(r.code===201&&r.data.run_id){accepted=r.data.run_id;say('Run '+accepted+' accepted; waiting for the device to report it.')}" " else say('Refused by the device (HTTP '+r.code+'): '+(r.data.error||'no reason given'));" " })" " .catch(function(){say('No response to the start request. The state above comes from the device; check it before retrying.')})" @@ -1100,8 +1106,12 @@ static const char run_page[] = " var id=status.run_id;aborting=true;render();" " var ctl=new AbortController(),timer=setTimeout(function(){ctl.abort()},4000);" " fetch('/api/v1/runs/'+encodeURIComponent(id)+'/abort',{method:'POST',signal:ctl.signal})" -" .then(function(res){return res.json().then(function(data){return {ok:res.ok,code:res.status,data:data}},function(err){if(ctl.signal.aborted)throw err;return {ok:res.ok,code:res.status,data:{}}})})" -" .then(function(r){say(r.ok?'Abort requested for run '+id+'.':'Abort refused (HTTP '+r.code+'): '+(r.data.error||'no reason given'))})" +" .then(function(res){return res.json().then(function(data){return {ok:res.ok,code:res.status,data:data}},function(err){return {ok:res.ok,code:res.status,data:{},lost:bodyLost(err)}})})" +" .then(function(r){" +" if(r.lost&&r.ok)say('Abort acknowledged by the device (HTTP '+r.code+') for run '+id+', but its reply did not arrive. The state above comes from the device.');" +" else if(r.lost)say('Abort refused (HTTP '+r.code+'); its reply did not arrive, so the reason is unavailable.');" +" else say(r.ok?'Abort requested for run '+id+'.':'Abort refused (HTTP '+r.code+'): '+(r.data.error||'no reason given'));" +" })" " .catch(function(){say('No response to the abort request.')})" " .then(function(){clearTimeout(timer);aborting=false;render();poll()});" " });" diff --git a/tests/test_run_page_js.py b/tests/test_run_page_js.py index 1f73eef..c9182b7 100644 --- a/tests/test_run_page_js.py +++ b/tests/test_run_page_js.py @@ -62,6 +62,9 @@ class FakeAbortController { reject(Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })); }); }) }); + // Headers arrive but the connection drops while the body is read. + if (r && r.dropBody) return Promise.resolve({ ok: r.code >= 200 && r.code < 300, status: r.code, + json: () => Promise.reject(new TypeError('network error')) }); // A complete response whose body is not JSON. if (r && r.badJson) return Promise.resolve({ ok: r.code >= 200 && r.code < 300, status: r.code, json: () => Promise.reject(new SyntaxError('Unexpected end of JSON input')) }); @@ -276,6 +279,26 @@ class FakeAbortController { await p.elapse(4000); s.push(p.snap()); out.abort_body_stall = { snaps: s, post: p.posts[0] }; } + { // start refused (409 headers) but the body stalls until the timeout + const d = device(IDLE); const p = page(d); await p.tick(0); + d.post = { code: 409, stallBody: true }; + await p.click('NVS_WRITE'); const s = [p.snap()]; + await p.elapse(4000); s.push(p.snap()); + out.start_409_stall = { snaps: s, post: p.posts[0] }; + } + { // abort refused (409 headers) but the body stalls until the timeout + const d = device(st('running', 'IDLE', 'r16', 'none')); const p = page(d); await p.tick(0); + d.abort = { code: 409, stallBody: true }; + await p.abort(); const s = [p.snap()]; + await p.elapse(4000); s.push(p.snap()); + out.abort_409_stall = { snaps: s, post: p.posts[0] }; + } + { // start accepted (201 headers) but the connection drops during the body + const d = device(IDLE); const p = page(d); await p.tick(0); + d.post = { code: 201, dropBody: true }; + await p.click('IDLE'); + out.start_body_drop = p.snap(); + } { // a complete response that is not JSON still falls back to an empty payload const d = device(IDLE); const p = page(d); await p.tick(0); d.post = { code: 409, badJson: true }; @@ -531,34 +554,74 @@ def test_answered_requests_clear_their_timeout_without_aborting(self): self.assertEqual(snap["pendingTimers"], [], snap) self.assertEqual(snap["abortedPosts"], 0, snap) - def test_start_body_stall_times_out_instead_of_reading_as_refused(self): + def test_start_201_body_stall_keeps_the_acknowledgement_without_a_run_id(self): t = self.out["start_body_stall"] starting, timed_out = t["snaps"] self.assertTrue(t["post"]["bodyStarted"]) self.assertEqual(starting["status"], "starting") self.assertTrue(t["post"]["aborted"]) - self.assertIn("No response to the start request", timed_out["message"]) - self.assertNotIn("Refused", timed_out["message"]) - self.assertNotIn("HTTP 201", timed_out["message"]) + message = timed_out["message"] + self.assertIn("acknowledged", message) + self.assertIn("HTTP 201", message) + self.assertIn("run ID is unknown", message) + self.assertNotIn("No response", message) + self.assertNotIn("Refused", message) self.assertNotEqual(timed_out["status"], "starting") self.assertGreater(timed_out["statusGets"], starting["statusGets"]) + # RUNNING and the run ID come from the status poll, not from the 201. self.assertEqual(timed_out["status"], "running") self.assertEqual(timed_out["id"], "r13") - def test_abort_body_stall_times_out_instead_of_reading_as_requested(self): + def test_start_201_body_drop_does_not_infer_running(self): + snap = self.out["start_body_drop"] + self.assertIn("HTTP 201", snap["message"]) + self.assertIn("run ID is unknown", snap["message"]) + self.assertNotIn("Refused", snap["message"]) + self.assertEqual(snap["state"], "READY") + self.assertEqual(snap["id"], "—") + + def test_abort_200_body_stall_keeps_the_acknowledgement(self): t = self.out["abort_body_stall"] requested, timed_out = t["snaps"] self.assertTrue(t["post"]["bodyStarted"]) self.assertTrue(requested["abortDisabled"]) self.assertTrue(t["post"]["aborted"]) - self.assertIn("No response to the abort request", timed_out["message"]) - self.assertNotIn("Abort requested", timed_out["message"]) + message = timed_out["message"] + self.assertIn("acknowledged", message) + self.assertIn("HTTP 200", message) + self.assertIn("r14", message) + self.assertNotIn("No response", message) + self.assertNotIn("ABORTED", message) self.assertFalse(timed_out["abortDisabled"]) self.assertGreater(timed_out["statusGets"], requested["statusGets"]) + # ABORTING comes from the status poll; ABORTED is never inferred. self.assertEqual(timed_out["status"], "aborting") - self.assertNotEqual(timed_out["status"], "aborted") self.assertTrue(timed_out["abortHidden"]) + def test_start_409_body_stall_keeps_the_refusal(self): + t = self.out["start_409_stall"] + starting, timed_out = t["snaps"] + self.assertTrue(t["post"]["aborted"]) + message = timed_out["message"] + self.assertIn("Refused by the device (HTTP 409)", message) + self.assertIn("reason is unavailable", message) + self.assertNotIn("No response", message) + self.assertEqual(timed_out["state"], "READY") + self.assertButtons(timed_out, False) + self.assertGreater(timed_out["statusGets"], starting["statusGets"]) + + def test_abort_409_body_stall_keeps_the_refusal(self): + t = self.out["abort_409_stall"] + requested, timed_out = t["snaps"] + self.assertTrue(t["post"]["aborted"]) + message = timed_out["message"] + self.assertIn("Abort refused (HTTP 409)", message) + self.assertIn("reason is unavailable", message) + self.assertNotIn("No response", message) + self.assertEqual(timed_out["status"], "running") + self.assertFalse(timed_out["abortDisabled"]) + self.assertGreater(timed_out["statusGets"], requested["statusGets"]) + def test_non_json_reply_still_reports_the_http_status(self): start, abort = self.out["bad_json"] self.assertEqual(start["message"], "Refused by the device (HTTP 409): no reason given")