From 269e507580eb368836fa5b111f8250447c4f84c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Wed, 14 Oct 2026 23:20:34 +0300 Subject: [PATCH] v3.28.7: the batch panel freeze ramBatchFetch's re-entrancy guard (_ramBatchFetching) is only cleared when the fetch settles, and ramBatchApplyStatus returned without rendering whenever it decided a fetch was due - so one stalled /api/ram-batch/status request (the browser's fetch has no timeout) froze the whole panel (counts, phase, notice, throbber) at the last successful render while the server kept installing (live 2026-10-14: 31 installs, the panel stuck at "Installed: 12"; the log showed all 31 attempts and a healthy /api/status cadence, so the data was there). - The summary render now runs on every poll before the fetch decision: the headline (phase, current card, counts, notice) always follows the 2 s /api/status poll; the detailed fetch only adds the attempts table. - A fetch guard older than RAM_BATCH_FETCH_STALE_MS (10 s) is released, so a stalled fetch cannot block the report forever - the next poll retries and the attempts table recovers. Reproduced with the page's real functions: 12 good fetches, the 13th hung -> the panel stuck at 12 (the reported number); with the fix it follows the summary to 31 and the stale guard retries. Tests: a hung fetch keeps the counts following the summary; a stale guard is released and retried (a fresh one is kept). 919 frontend + 928 Python; sw cache simple-v445. --- frontend/index.html | 18 ++++++-- frontend/sw.js | 2 +- frontend/tests/ram_batch.test.js | 73 +++++++++++++++++++++++++++++++- pyproject.toml | 2 +- pysim_simple_server/server.py | 2 +- 5 files changed, 90 insertions(+), 7 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index b1ecce1..aadaadc 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1910,7 +1910,7 @@ // ===== Version ===== // Single source of truth for the PWA version: shown in the header and used // by the server version check in pysimConnect(). -const SIMPLE_VERSION = '3.28.6'; +const SIMPLE_VERSION = '3.28.7'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== view history (the browser Back/Forward buttons) ===== @@ -10155,8 +10155,11 @@ function ramHideProgress() { // (the attempts list) is fetched only when the summary says it changed. let _ramBatchLast = null; // the last detailed /api/ram-batch/status let _ramBatchFetching = false; +let _ramBatchFetchAt = 0; // when the in-flight fetch started (the watchdog) let _ramBatchSummary = null; // /api/status.ram_batch let _ramBatchProgress = null; // /api/status.ram_progress +// A fetch that never settled must not block the report forever (v3.28.7). +const RAM_BATCH_FETCH_STALE_MS = 10000; function ramBatchPanelVisible() { const op = document.getElementById('ram-op'); @@ -10167,6 +10170,7 @@ function ramBatchPanelVisible() { async function ramBatchFetch() { if (_ramBatchFetching) return; _ramBatchFetching = true; + _ramBatchFetchAt = Date.now(); try { _ramBatchLast = await pysimFetch('/api/ram-batch/status'); } catch (e) { /* the next poll retries */ } @@ -10181,14 +10185,22 @@ function ramBatchApplyStatus(status) { _ramBatchSummary = status.ram_batch || null; _ramBatchProgress = status.ram_progress || null; if (!ramBatchPanelVisible()) return; + // The summary carries the headline (the phase, the current card, the + // counts, the notice): render it on every poll, even when a detailed fetch + // is due or stuck - a hung fetch used to freeze the whole panel at the + // last successful render (v3.28.7). + ramBatchRender(); + if (_ramBatchFetching && Date.now() - _ramBatchFetchAt > RAM_BATCH_FETCH_STALE_MS) { + // the in-flight fetch is stale (a stalled connection): release the + // guard so this poll retries and the report can recover + _ramBatchFetching = false; + } const total = _ramBatchSummary ? (_ramBatchSummary.attempts_total || 0) : 0; const active = _ramBatchSummary ? !!_ramBatchSummary.active : false; if (!_ramBatchLast || (_ramBatchLast.attempts_total || 0) !== total || !!_ramBatchLast.active !== active) { ramBatchFetch(); - return; } - ramBatchRender(); } function ramBatchRender() { diff --git a/frontend/sw.js b/frontend/sw.js index 36d56ce..7d2b5b6 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v444'; +const CACHE = 'simple-v445'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/ram_batch.test.js b/frontend/tests/ram_batch.test.js index 573d9cd..8384de8 100644 --- a/frontend/tests/ram_batch.test.js +++ b/frontend/tests/ram_batch.test.js @@ -29,6 +29,9 @@ for (const fn of ['ramBatchPanelVisible', 'ramBatchAttemptsHtml', 'ramBatchRende code += extractFunc(html, fn, ASYNC_FNS.indexOf(fn) >= 0) + '\n'; } +const RAM_BATCH_FETCH_STALE_MS = parseInt( + /const RAM_BATCH_FETCH_STALE_MS = (\d+);/.exec(html)[1], 10); + const els = {}; function setEl(id, value) { els[id] = { @@ -54,7 +57,11 @@ const app = new Function('els', ` var t = k => k; var isViewVisible = () => sandbox.visibility; var alert = m => sandbox.alerts.push(String(m)); - var pysimFetch = async (p) => { sandbox.fetched.push(p); return sandbox.replies[p] || null; }; + var pysimFetch = async (p) => { + sandbox.fetched.push(p); + if (sandbox.hangs) return new Promise(() => {}); // a stalled connection + return sandbox.replies[p] || null; + }; var pysimFetchOk = async (p, body) => { sandbox.fetched.push(p); sandbox.bodies = sandbox.bodies || {}; @@ -68,12 +75,17 @@ const app = new Function('els', ` var pysimApplyAvailability = () => {}; var _ramBatchLast = null; var _ramBatchFetching = false; + var _ramBatchFetchAt = 0; var _ramBatchSummary = null; var _ramBatchProgress = null; + var RAM_BATCH_FETCH_STALE_MS = ${RAM_BATCH_FETCH_STALE_MS}; ${code} sandbox.getLast = () => _ramBatchLast; sandbox.setLast = v => { _ramBatchLast = v; }; sandbox.getSummary = () => _ramBatchSummary; + sandbox.isFetching = () => _ramBatchFetching; + sandbox.setFetchingStale = ms => { _ramBatchFetching = true; _ramBatchFetchAt = Date.now() - ms; }; + sandbox.resetFetching = () => { _ramBatchFetching = false; }; sandbox.getProgress = () => _ramBatchProgress; sandbox.ramBatchPanelVisible = ramBatchPanelVisible; sandbox.ramBatchAttemptsHtml = ramBatchAttemptsHtml; @@ -265,6 +277,65 @@ test('the panel headline follows the live summary (phase and step)', () => { assert.strictEqual(els['ram-batch-state'].textContent, 'Insert next card. Waiting for a card...'); }); +test('a hung detailed fetch does not freeze the counts (v3.28.7)', async () => { + clearEls(); + setEl('ram-op', 'batch'); + setEl('ram-batch-panel', ''); + setEl('ram-batch-start', ''); + setEl('ram-batch-stop', ''); + setEl('ram-batch-clear', ''); + setEl('ram-batch-spin', ''); + setEl('ram-batch-state', ''); + setEl('ram-batch-notice', ''); + setEl('ram-batch-counts', ''); + setEl('ram-batch-attempts', ''); + app.hangs = true; // every detailed fetch stalls + const sum = n => ({ ram_batch: { active: true, phase: 'waiting_card', attempts_total: n, + counts: { installed: n, failed: 0, skipped: 0 } } }); + app.ramBatchApplyStatus(sum(12)); + await new Promise(r => setImmediate(r)); + assert.match(els['ram-batch-counts'].textContent, /Installed: 12/); + // the fetch never settles, but the headline must follow the summary + app.ramBatchApplyStatus(sum(31)); + await new Promise(r => setImmediate(r)); + assert.match(els['ram-batch-counts'].textContent, /Installed: 31/); + app.hangs = false; +}); + +test('a stale fetch guard is released so the report recovers (v3.28.7)', async () => { + clearEls(); + setEl('ram-op', 'batch'); + setEl('ram-batch-panel', ''); + setEl('ram-batch-start', ''); + setEl('ram-batch-stop', ''); + setEl('ram-batch-clear', ''); + setEl('ram-batch-spin', ''); + setEl('ram-batch-state', ''); + setEl('ram-batch-notice', ''); + setEl('ram-batch-counts', ''); + setEl('ram-batch-attempts', ''); + app.fetched = []; + app.replies['/api/ram-batch/status'] = { active: true, phase: 'waiting_card', attempts: [], + attempts_total: 31, counts: { installed: 31, failed: 0, skipped: 0 } }; + // the guard stuck for 11 s: the poll releases it and retries + app.setFetchingStale(11000); + app.ramBatchApplyStatus({ ram_batch: { active: true, phase: 'waiting_card', attempts_total: 31, + counts: { installed: 31, failed: 0, skipped: 0 } } }); + // the stale guard is released and this poll retries (the fetch is issued) + await new Promise(r => setImmediate(r)); + assert.deepStrictEqual(app.fetched, ['/api/ram-batch/status']); + assert.ok(!app.isFetching(), 'the guard must be clear again after the fetch'); + assert.match(els['ram-batch-counts'].textContent, /Installed: 31/); + // a fresh guard is kept (no duplicate fetch) + app.fetched = []; + app.setFetchingStale(1000); + app.ramBatchApplyStatus({ ram_batch: { active: true, phase: 'waiting_card', attempts_total: 31, + counts: { installed: 31, failed: 0, skipped: 0 } } }); + await new Promise(r => setImmediate(r)); + assert.deepStrictEqual(app.fetched, []); + app.resetFetching(); +}); + test('the stop and clear calls hit their endpoints', async () => { clearEls(); app.fetched = []; diff --git a/pyproject.toml b/pyproject.toml index 5b399e3..a8caa52 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "3.28.6" +version = "3.28.7" description = "HTTP REST server wrapping pysim for the SIMple PWA" requires-python = ">=3.8" # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py index 2780d1f..ee63dd9 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -37,7 +37,7 @@ from osmocom.tlv import BER_TLV_IE from osmocom.utils import rpad -VERSION = '3.28.6' +VERSION = '3.28.7' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE