diff --git a/frontend/index.html b/frontend/index.html
index cfb45fc..1f37acd 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1732,7 +1732,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.6.34';
+const SIMPLE_VERSION = '3.6.35';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching =====
@@ -9651,9 +9651,33 @@ async function stkSubItemClick(itemId) {
}
}
+// Close the pending-command panel when the server answered the command by
+// itself (the menu watchdog's timeout TERMINAL RESPONSE, or a new selection
+// that shadowed it): the dialog can no longer be answered (v3.6.35).
+function stkMenuPanelReset() {
+ const panel = document.getElementById('stk-menu-panel');
+ if (panel) panel.classList.add('hidden');
+ const btns = document.getElementById('stk-menu-buttons');
+ if (btns) btns.classList.add('hidden');
+ const back = document.getElementById('stk-back-btn');
+ if (back) back.style.display = 'none';
+}
+
+// The /api/menu-respond reply for a command the server already answered.
+function stkNoPendingError(data) {
+ return !!(data && typeof data.error === 'string'
+ && data.error.indexOf('no pending command') >= 0);
+}
+
async function stkMenuRespond(result) {
try {
const data = await pysimFetch('/api/menu-respond', { result: result });
+ if (stkNoPendingError(data)) {
+ // Already answered (timeout / shadowed): close the stale panel
+ stkMenuPanelReset();
+ stkCheckMenu();
+ return;
+ }
if (result === 'cancel' || result === 'back' || result === 'timeout') {
if (data && (data.type === 'select_item' || data.type === 'display_text')) {
stkMenuHandleResponse(data);
@@ -11821,7 +11845,11 @@ function pysimStartBackendPoll() {
pysimFetch('/api/test/status').catch(() => null),
]);
pysimUpdatePollUI(ps.enabled, ps.interval, ps.card_disabled, ps.warning);
- if (pysimStkStatusChanged(stk) && isViewVisible('tab-phone')) stkCheckMenu();
+ const stkChanged = pysimStkStatusChanged(stk);
+ if (stkChanged && isViewVisible('tab-phone')) stkCheckMenu();
+ // A command the server answered by itself must not leave a stale
+ // dialog behind (the panel is not refreshed when the tab is hidden)
+ if (stkChanged && !stk.pending) stkMenuPanelReset();
if (ts) {
const wasRunning = testRunActive();
_testRunState = ts;
diff --git a/frontend/sw.js b/frontend/sw.js
index a404d33..b64a2bb 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'simple-v307';
+const CACHE = 'simple-v308';
const URLS = [
'index.html',
'help.html',
diff --git a/frontend/tests/stk_menu.test.js b/frontend/tests/stk_menu.test.js
index dcb7494..ae59ec8 100644
--- a/frontend/tests/stk_menu.test.js
+++ b/frontend/tests/stk_menu.test.js
@@ -22,6 +22,8 @@ function extractFunc(src, name, asyncFn) {
}
let code = extractFunc(html, 'stkMenuRespond', true) + '\n';
+code += extractFunc(html, 'stkNoPendingError') + '\n';
+code += extractFunc(html, 'stkMenuPanelReset') + '\n';
code += extractFunc(html, 'stkMenuNaiSuffix') + '\n';
code += extractFunc(html, 'stkMenuItemsHtml') + '\n';
code += 'globalThis.esc = s => s;\n';
@@ -32,10 +34,12 @@ function setup(response) {
globalThis.pysimFetch = async () => response;
globalThis.stkMenuHandleResponse = d => { calls.handled = d; };
globalThis.stkMenuRenderItems = () => { calls.rendered++; };
+ globalThis.stkCheckMenu = () => {};
const btns = { classList: { add: () => {} } };
const back = { style: {} };
globalThis.document = {
- getElementById: id => (id === 'stk-menu-buttons' ? btns : id === 'stk-back-btn' ? back : { innerHTML: '' }),
+ getElementById: id => (id === 'stk-menu-buttons' ? btns : id === 'stk-back-btn' ? back
+ : { innerHTML: '', classList: { add: () => {} } }),
};
return calls;
}
@@ -114,3 +118,17 @@ test('ok navigates with the server response', async () => {
assert.strictEqual(calls.handled, data);
assert.strictEqual(calls.rendered, 0);
});
+
+test('stkNoPendingError detects the already-answered reply', () => {
+ assert.strictEqual(stkNoPendingError({ error: 'no pending command' }), true);
+ assert.strictEqual(stkNoPendingError({ sw: '9000', type: 'done' }), false);
+ assert.strictEqual(stkNoPendingError({ error: 'other' }), false);
+ assert.strictEqual(stkNoPendingError(null), false);
+});
+
+test('an already-answered respond does not reach the dialog handler', async () => {
+ const calls = setup({ error: 'no pending command' });
+ await stkMenuRespond('ok');
+ assert.strictEqual(calls.handled, null);
+ assert.strictEqual(calls.rendered, 0);
+});
diff --git a/pyproject.toml b/pyproject.toml
index 0fcd90f..9fa8b7b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-simple-server"
-version = "3.6.34"
+version = "3.6.35"
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 2e73b48..673cbf5 100644
--- a/pysim_simple_server/server.py
+++ b/pysim_simple_server/server.py
@@ -31,7 +31,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad
-VERSION = '3.6.34'
+VERSION = '3.6.35'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -5033,7 +5033,13 @@ class PysimHandler(BaseHTTPRequestHandler):
self.send_header('Content-Type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
- self.wfile.write(json.dumps(data, ensure_ascii=False).encode())
+ try:
+ self.wfile.write(json.dumps(data, ensure_ascii=False).encode())
+ except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
+ # The browser navigated away or reloaded while the response was in
+ # flight (a cancelled poll): the request is done and the client is
+ # gone - one line, no traceback (v3.6.35).
+ sys.stderr.write('CLIENT GONE: %s %s\n' % (self.command, self.path))
def _read_body(self):
length = int(self.headers.get('Content-Length', 0))
@@ -5104,7 +5110,10 @@ class PysimHandler(BaseHTTPRequestHandler):
if last_modified:
self.send_header('Last-Modified', last_modified)
self.end_headers()
- self.wfile.write(data)
+ try:
+ self.wfile.write(data)
+ except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
+ sys.stderr.write('CLIENT GONE: GET %s\n' % rel)
def do_GET(self):
if _test_request_blocked(self.path):
@@ -5698,6 +5707,7 @@ class PysimHandler(BaseHTTPRequestHandler):
self._send_json({'error': 'no terminal profile configured'}, 400)
self._log_resp({'error': 'no terminal profile configured'})
return
+ _finish_pending_menu(self.server, scc)
sys.stderr.write('RESCUE: re-sending TERMINAL PROFILE\n')
resp = _resend_terminal_profile(self.server, scc)
self._send_json(resp)
@@ -5722,6 +5732,9 @@ class PysimHandler(BaseHTTPRequestHandler):
self._send_json({'error': 'no terminal profile configured'}, 400)
self._log_resp({'error': 'no terminal profile configured'})
return
+ # Never re-send the TP while a FETCHed command awaits its
+ # TERMINAL RESPONSE: answer it with a cancel TR first (v3.6.35).
+ _finish_pending_menu(self.server, scc)
sys.stderr.write('TERMINAL-PROFILE: re-sending %s\n' % self.server.terminal_profile)
try:
resp = _resend_terminal_profile(self.server, scc)