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.
This commit is contained in:
2026-10-14 23:20:34 +03:00
parent 408ad87a0e
commit 269e507580
5 changed files with 90 additions and 7 deletions
+15 -3
View File
@@ -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() {
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v444';
const CACHE = 'simple-v445';
const URLS = [
'index.html',
'help.html',
+72 -1
View File
@@ -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 = [];