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