fix: cached GETs bypass the card lock; compact install step lines (v3.6.47)
The install modal's progress bar froze around 70% and jumped to 100% at
the end: not a math bug - the UI's other polls (/api/stk-status,
/api/poll-status, /api/proactive-log, /api/menu, ...) were card-locked in
do_GET even though they only read cached state, queued behind the install
and exhausted the browser's 6-connections-per-host budget, starving
/api/status. _CARD_FREE_GET lists every card-free GET (status, version,
stk-status, poll-status, proactive-log, menu, events, terminal-profile,
pli-*, commands, net-state, mcc-mnc, scp81/bip status|script|log);
/api/cardinfo and /api/esim/* stay locked (they touch the card).
tests/test_fetch_routes.py holds _CARD_LOCK and asserts the cached
endpoints still answer - verified to time out on the pre-fix list.
The install modal is wider (max-w-3xl) and successful step lines are
compact: '✅ Step 3: LOAD (1/240) - 6101 · 274 B / 3 SMS' (no verbose SW
name, B instead of bytes, the SMS count only when >1); failed steps keep
the full detail and the hint.
sw cache simple-v320; tests: 533 Python / 684 Node.
This commit is contained in:
+13
-2
@@ -1725,7 +1725,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="ram-op-modal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center hidden">
|
<div id="ram-op-modal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center hidden">
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-lg p-4 max-w-lg w-full max-h-[85vh] flex flex-col">
|
<div class="bg-white dark:bg-slate-800 rounded-lg p-4 max-w-3xl w-full max-h-[85vh] flex flex-col">
|
||||||
<div class="flex justify-between items-center mb-2 gap-3">
|
<div class="flex justify-between items-center mb-2 gap-3">
|
||||||
<span id="ram-op-title" class="font-bold text-sm flex-1"></span>
|
<span id="ram-op-title" class="font-bold text-sm flex-1"></span>
|
||||||
<button id="ram-op-close" onclick="ramOpModalClose()" disabled class="px-3 py-1 text-xs rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Close">Close</button>
|
<button id="ram-op-close" onclick="ramOpModalClose()" disabled class="px-3 py-1 text-xs rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Close">Close</button>
|
||||||
@@ -1746,7 +1746,7 @@
|
|||||||
// ===== Version =====
|
// ===== Version =====
|
||||||
// Single source of truth for the PWA version: shown in the header and used
|
// Single source of truth for the PWA version: shown in the header and used
|
||||||
// by the server version check in pysimConnect().
|
// by the server version check in pysimConnect().
|
||||||
const SIMPLE_VERSION = '3.6.46';
|
const SIMPLE_VERSION = '3.6.47';
|
||||||
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
||||||
|
|
||||||
// ===== Tab switching =====
|
// ===== Tab switching =====
|
||||||
@@ -9432,6 +9432,16 @@ function ramStepLine(s, idx, comps, blockSize) {
|
|||||||
const ok = !s.por_error && (s.por_status === 'por_ok' || s.por_status === 'no_por');
|
const ok = !s.por_error && (s.por_status === 'por_ok' || s.por_status === 'no_por');
|
||||||
let line = (ok ? '\u2705' : '\u274c') + ' ' + t('Step') + ' ' + (idx + 1) + ': ' +
|
let line = (ok ? '\u2705' : '\u274c') + ' ' + t('Step') + ' ' + (idx + 1) + ': ' +
|
||||||
s.name + ' \u2014 ';
|
s.name + ' \u2014 ';
|
||||||
|
if (ok) {
|
||||||
|
// successful step: the verdict symbol carries the meaning - keep the
|
||||||
|
// remote status word (without its verbose name), the size and the SMS
|
||||||
|
// count only when the packet was concatenated
|
||||||
|
if (s.por_sw) line += s.por_sw;
|
||||||
|
else if (s.por_status === 'no_por') line += t('no PoR');
|
||||||
|
else line += t('OK');
|
||||||
|
if (s.bytes) line += ' \u00b7 ' + s.bytes + ' B';
|
||||||
|
if (s.segments > 1) line += ' / ' + s.segments + ' SMS';
|
||||||
|
} else {
|
||||||
if (s.por_status === 'por_ok') line += t('PoR ok');
|
if (s.por_status === 'por_ok') line += t('PoR ok');
|
||||||
else if (s.por_status === 'no_por') line += t('no PoR');
|
else if (s.por_status === 'no_por') line += t('no PoR');
|
||||||
else if (s.por_status && s.por_status.indexOf('por_error') === 0) {
|
else if (s.por_status && s.por_status.indexOf('por_error') === 0) {
|
||||||
@@ -9447,6 +9457,7 @@ function ramStepLine(s, idx, comps, blockSize) {
|
|||||||
if (s.bytes && s.segments) {
|
if (s.bytes && s.segments) {
|
||||||
line += ' \u00b7 ' + s.bytes + ' ' + t('bytes') + ' / ' + s.segments + ' SMS';
|
line += ' \u00b7 ' + s.bytes + ' ' + t('bytes') + ' / ' + s.segments + ' SMS';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const sc = ramStepComponents(s.name, comps, blockSize);
|
const sc = ramStepComponents(s.name, comps, blockSize);
|
||||||
if (sc) {
|
if (sc) {
|
||||||
if (sc.completed.length) line += ' \u00b7 ' + t('completes') + ' ' + sc.completed[sc.completed.length - 1];
|
if (sc.completed.length) line += ' \u00b7 ' + t('completes') + ' ' + sc.completed[sc.completed.length - 1];
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'simple-v319';
|
const CACHE = 'simple-v320';
|
||||||
const URLS = [
|
const URLS = [
|
||||||
'index.html',
|
'index.html',
|
||||||
'help.html',
|
'help.html',
|
||||||
|
|||||||
@@ -486,3 +486,9 @@ test('the RAM form shows Card preset and Operation in one row', () => {
|
|||||||
const row = /<div class="mb-3 flex gap-2 items-end">\s*<div class="flex-1">\s*<label[^>]*data-l10n="Card preset"[\s\S]*?id="ram-card-sel"[\s\S]*?data-l10n="Operation"[\s\S]*?id="ram-op"[\s\S]*?data-l10n="Execute"/.exec(html);
|
const row = /<div class="mb-3 flex gap-2 items-end">\s*<div class="flex-1">\s*<label[^>]*data-l10n="Card preset"[\s\S]*?id="ram-card-sel"[\s\S]*?data-l10n="Operation"[\s\S]*?id="ram-op"[\s\S]*?data-l10n="Execute"/.exec(html);
|
||||||
assert.ok(row, 'Card preset + Operation + Execute must share one row');
|
assert.ok(row, 'Card preset + Operation + Execute must share one row');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the RAM operation modal is wide enough for the step lines', () => {
|
||||||
|
const m = /<div id="ram-op-modal"[\s\S]*?<div class="([^"]*max-w-[^"]*)"/.exec(html);
|
||||||
|
assert.ok(m, 'RAM op modal not found');
|
||||||
|
assert.ok(m[1].includes('max-w-3xl'), 'the modal was widened to fit the step lines: ' + m[1]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -374,12 +374,14 @@ test('ramGetStatusApdu builds the compact chain, never the expanded form', () =>
|
|||||||
test('ramStepLine shows the PoR verdict and the remote status word', () => {
|
test('ramStepLine shows the PoR verdict and the remote status word', () => {
|
||||||
globalThis.t = s => s;
|
globalThis.t = s => s;
|
||||||
globalThis.lookupSw = (a, b) => (a + b === '6700' ? 'Wrong length in Lc' : '');
|
globalThis.lookupSw = (a, b) => (a + b === '6700' ? 'Wrong length in Lc' : '');
|
||||||
|
// successful steps are compact: verdict symbol, remote SW (no name),
|
||||||
|
// size; the SMS count only for concatenated packets
|
||||||
const okLine = ramStepLine({ name: 'INSTALL [for load]', por_status: 'por_ok',
|
const okLine = ramStepLine({ name: 'INSTALL [for load]', por_status: 'por_ok',
|
||||||
por_sw: '9000', sw: '9000', bytes: 50, segments: 1 }, 0);
|
por_sw: '9000', sw: '9000', bytes: 50, segments: 1 }, 0);
|
||||||
assert.ok(okLine.startsWith('\u2705'), okLine);
|
assert.strictEqual(okLine, '\u2705 Step 1: INSTALL [for load] \u2014 9000 \u00b7 50 B');
|
||||||
assert.ok(okLine.includes('PoR ok'), okLine);
|
const multi = ramStepLine({ name: 'LOAD (1/64)', por_status: 'por_ok',
|
||||||
assert.ok(okLine.includes('remote SW 9000'), okLine);
|
por_sw: '6101', bytes: 274, segments: 3 }, 1);
|
||||||
assert.ok(okLine.includes('50 bytes / 1 SMS'), okLine);
|
assert.strictEqual(multi, '\u2705 Step 2: LOAD (1/64) \u2014 6101 \u00b7 274 B / 3 SMS');
|
||||||
const badLine = ramStepLine({ name: 'LOAD (1/9)', por_status: 'por_ok', por_sw: '6700',
|
const badLine = ramStepLine({ name: 'LOAD (1/9)', por_status: 'por_ok', por_sw: '6700',
|
||||||
por_error: 'remote SW 6700', sw: '9000', bytes: 274, segments: 3 }, 1);
|
por_error: 'remote SW 6700', sw: '9000', bytes: 274, segments: 3 }, 1);
|
||||||
assert.ok(badLine.startsWith('\u274c'), badLine);
|
assert.ok(badLine.startsWith('\u274c'), badLine);
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pysim-simple-server"
|
name = "pysim-simple-server"
|
||||||
version = "3.6.46"
|
version = "3.6.47"
|
||||||
description = "HTTP REST server wrapping pysim for the SIMple PWA"
|
description = "HTTP REST server wrapping pysim for the SIMple PWA"
|
||||||
requires-python = ">=3.8"
|
requires-python = ">=3.8"
|
||||||
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
|
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ from osmocom.tlv import BER_TLV_IE
|
|||||||
from osmocom.utils import rpad
|
from osmocom.utils import rpad
|
||||||
|
|
||||||
|
|
||||||
VERSION = '3.6.46'
|
VERSION = '3.6.47'
|
||||||
|
|
||||||
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
||||||
|
|
||||||
@@ -4864,6 +4864,20 @@ _TEST_BLOCKED_PATHS = frozenset([
|
|||||||
'/api/scp81/gen-install', '/api/scp81/log-clear',
|
'/api/scp81/gen-install', '/api/scp81/log-clear',
|
||||||
'/api/bip/control', '/api/bip/log-clear',
|
'/api/bip/control', '/api/bip/log-clear',
|
||||||
])
|
])
|
||||||
|
# GET endpoints that only read cached state (or the static file tree): served
|
||||||
|
# without _CARD_LOCK so they stay answerable while a long card operation runs
|
||||||
|
# (see do_GET). Card-touching GETs are deliberately absent: /api/cardinfo
|
||||||
|
# (runs cardinfo on the card) and /api/esim/* (ES10 selection + STORE DATA).
|
||||||
|
_CARD_FREE_GET = frozenset([
|
||||||
|
'/api/status', '/api/test/status', '/api/version',
|
||||||
|
'/api/stk-status', '/api/poll-status', '/api/proactive-log',
|
||||||
|
'/api/menu', '/api/events', '/api/terminal-profile',
|
||||||
|
'/api/pli-qualifiers', '/api/pli-dict', '/api/commands',
|
||||||
|
'/api/net-state', '/api/mcc-mnc',
|
||||||
|
'/api/scp81/status', '/api/bip/status', '/api/scp81/script',
|
||||||
|
'/api/scp81/log', '/api/bip/log',
|
||||||
|
])
|
||||||
|
|
||||||
_TEST_COMMAND_TYPES = {name.upper(): code for code, name in PROACTIVE_TYPE_NAMES.items()}
|
_TEST_COMMAND_TYPES = {name.upper(): code for code, name in PROACTIVE_TYPE_NAMES.items()}
|
||||||
|
|
||||||
|
|
||||||
@@ -5424,15 +5438,17 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
self._send_json({'error': 'test script running - card commands are '
|
self._send_json({'error': 'test script running - card commands are '
|
||||||
'blocked until it finishes'}, 409)
|
'blocked until it finishes'}, 409)
|
||||||
return
|
return
|
||||||
# /api/status and /api/test/status are pure cached state (no card I/O);
|
# Pure cached state (no card I/O) is served without the lock: the UI
|
||||||
# keeping them out of the lock lets the UI report 'initializing' while a
|
# must be able to report 'initializing' while a long equip (or a test
|
||||||
# long equip (or a test script step) holds the card lock. Result-shaping
|
# script step) holds the card lock. The list is deliberately wide:
|
||||||
# masks everything card-derived when the session is not connected.
|
# with a long RAM install the UI polls several of these every few
|
||||||
# Static PWA files are card-free too: they must stay answerable while a
|
# seconds, and if they queued behind the install they would eat the
|
||||||
# long card operation holds the lock (the server is threaded, so this
|
# browser's per-host connection budget - starving /api/status (the
|
||||||
# bypass is what actually serves the modal's progress poll and page
|
# modal's progress bar froze around 70% and jumped to 100% at the end,
|
||||||
# assets during a RAM install).
|
# live 2026-09-29). Static PWA files are card-free too. Card-touching
|
||||||
if self.path in ('/api/status', '/api/test/status') or not self.path.startswith('/api/'):
|
# GETs (cardinfo, esim/*) stay locked below.
|
||||||
|
path = self.path.split('?', 1)[0]
|
||||||
|
if path in _CARD_FREE_GET or not path.startswith('/api/'):
|
||||||
self._do_GET()
|
self._do_GET()
|
||||||
return
|
return
|
||||||
# Serialize all card access: the background STATUS poll runs in its own
|
# Serialize all card access: the background STATUS poll runs in its own
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import urllib.error
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
from pysim_simple_server.__main__ import _build_http_server
|
from pysim_simple_server.__main__ import _build_http_server
|
||||||
from pysim_simple_server.server import PysimHandler
|
from pysim_simple_server.server import PysimHandler, _CARD_FREE_GET, _CARD_LOCK
|
||||||
|
|
||||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
@@ -109,3 +109,53 @@ class TestScriptHttpTests(unittest.TestCase):
|
|||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
|
|
||||||
|
class CardFreeGetTests(unittest.TestCase):
|
||||||
|
"""Cached GETs must answer while the card lock is held.
|
||||||
|
|
||||||
|
During a long RAM install the UI polls several cached endpoints every few
|
||||||
|
seconds; while they queued behind the install they ate the browser's
|
||||||
|
per-host connection budget and starved /api/status - the modal's progress
|
||||||
|
bar froze around 70% and jumped to 100% at the end (live 2026-09-29).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.server = _build_http_server('127.0.0.1', 0, PysimHandler)
|
||||||
|
self.server.log_requests = False
|
||||||
|
self.server.app = None
|
||||||
|
self.server.sl = None
|
||||||
|
self.server.scc = None
|
||||||
|
self.server.stk_pending = None
|
||||||
|
self.server.menu_active = False
|
||||||
|
self.server.sim_menu = None
|
||||||
|
self.server.event_list = None
|
||||||
|
self.server.terminal_profile = None
|
||||||
|
self.server.cli_terminal_profile = None
|
||||||
|
threading.Thread(target=self.server.serve_forever, daemon=True).start()
|
||||||
|
self.port = self.server.server_address[1]
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.server.shutdown()
|
||||||
|
self.server.server_close()
|
||||||
|
|
||||||
|
def _get(self, path, timeout=2):
|
||||||
|
return urllib.request.urlopen(
|
||||||
|
'http://127.0.0.1:%d%s' % (self.port, path), timeout=timeout)
|
||||||
|
|
||||||
|
def test_cached_endpoints_answer_while_the_card_lock_is_held(self):
|
||||||
|
with _CARD_LOCK:
|
||||||
|
for path in ('/api/status', '/api/stk-status', '/api/poll-status',
|
||||||
|
'/api/proactive-log', '/api/menu', '/api/events'):
|
||||||
|
with self.subTest(path=path):
|
||||||
|
with self._get(path) as res:
|
||||||
|
self.assertEqual(res.status, 200)
|
||||||
|
|
||||||
|
def test_card_touching_gets_stay_locked(self):
|
||||||
|
# cardinfo runs a card command; the ES10 endpoints select/STORE DATA
|
||||||
|
for path in ('/api/cardinfo', '/api/esim/chip', '/api/esim/profiles'):
|
||||||
|
self.assertNotIn(path, _CARD_FREE_GET)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user