From bd5dc69c4ef7c84e22cf9b4efeb6b5d93f2b9b02 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: Tue, 29 Sep 2026 00:37:56 +0300 Subject: [PATCH] fix: drop stale STK dialogs, finish pending on TP re-send, mute client-gone (v3.6.35) The live log showed the "stuck" state: after the menu watchdog (60 s) answered a pending command, the PWA kept the panel; the next click got {'error': 'no pending command'}, 400 and that reply was fed to the dialog handler as if it were a card answer. A page reload then cancelled in-flight polls, flooding the log with BrokenPipeError tracebacks. - stkMenuRespond detects the already-answered reply (stkNoPendingError) and closes the panel (stkMenuPanelReset) instead of treating it as an answer; the 5 s status poll drops the panel whenever the server reports pending: false, even when the Phone tab is hidden. - /api/terminal-profile (POST) and /api/rescue finish a pending command with a cancel TR first, so re-sending the TP can never shadow an unanswered FETCH. - _send_json and _serve_static swallow BrokenPipe/ConnectionReset/ ConnectionAborted with one "CLIENT GONE" line (a reload cancels polls); the handlers' state changes still stand. Tests: stkNoPendingError + the already-answered respond path; version 3.6.35; sw simple-v308; stylesheet rebuilt. --- frontend/index.html | 32 ++++++++++++++++++++++++++++++-- frontend/sw.js | 2 +- frontend/tests/stk_menu.test.js | 20 +++++++++++++++++++- pyproject.toml | 2 +- pysim_simple_server/server.py | 19 ++++++++++++++++--- 5 files changed, 67 insertions(+), 8 deletions(-) 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)