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.
This commit is contained in:
+30
-2
@@ -1732,7 +1732,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.34';
|
const SIMPLE_VERSION = '3.6.35';
|
||||||
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
||||||
|
|
||||||
// ===== Tab switching =====
|
// ===== 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) {
|
async function stkMenuRespond(result) {
|
||||||
try {
|
try {
|
||||||
const data = await pysimFetch('/api/menu-respond', { result: result });
|
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 (result === 'cancel' || result === 'back' || result === 'timeout') {
|
||||||
if (data && (data.type === 'select_item' || data.type === 'display_text')) {
|
if (data && (data.type === 'select_item' || data.type === 'display_text')) {
|
||||||
stkMenuHandleResponse(data);
|
stkMenuHandleResponse(data);
|
||||||
@@ -11821,7 +11845,11 @@ function pysimStartBackendPoll() {
|
|||||||
pysimFetch('/api/test/status').catch(() => null),
|
pysimFetch('/api/test/status').catch(() => null),
|
||||||
]);
|
]);
|
||||||
pysimUpdatePollUI(ps.enabled, ps.interval, ps.card_disabled, ps.warning);
|
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) {
|
if (ts) {
|
||||||
const wasRunning = testRunActive();
|
const wasRunning = testRunActive();
|
||||||
_testRunState = ts;
|
_testRunState = ts;
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'simple-v307';
|
const CACHE = 'simple-v308';
|
||||||
const URLS = [
|
const URLS = [
|
||||||
'index.html',
|
'index.html',
|
||||||
'help.html',
|
'help.html',
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ function extractFunc(src, name, asyncFn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let code = extractFunc(html, 'stkMenuRespond', true) + '\n';
|
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, 'stkMenuNaiSuffix') + '\n';
|
||||||
code += extractFunc(html, 'stkMenuItemsHtml') + '\n';
|
code += extractFunc(html, 'stkMenuItemsHtml') + '\n';
|
||||||
code += 'globalThis.esc = s => s;\n';
|
code += 'globalThis.esc = s => s;\n';
|
||||||
@@ -32,10 +34,12 @@ function setup(response) {
|
|||||||
globalThis.pysimFetch = async () => response;
|
globalThis.pysimFetch = async () => response;
|
||||||
globalThis.stkMenuHandleResponse = d => { calls.handled = d; };
|
globalThis.stkMenuHandleResponse = d => { calls.handled = d; };
|
||||||
globalThis.stkMenuRenderItems = () => { calls.rendered++; };
|
globalThis.stkMenuRenderItems = () => { calls.rendered++; };
|
||||||
|
globalThis.stkCheckMenu = () => {};
|
||||||
const btns = { classList: { add: () => {} } };
|
const btns = { classList: { add: () => {} } };
|
||||||
const back = { style: {} };
|
const back = { style: {} };
|
||||||
globalThis.document = {
|
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;
|
return calls;
|
||||||
}
|
}
|
||||||
@@ -114,3 +118,17 @@ test('ok navigates with the server response', async () => {
|
|||||||
assert.strictEqual(calls.handled, data);
|
assert.strictEqual(calls.handled, data);
|
||||||
assert.strictEqual(calls.rendered, 0);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pysim-simple-server"
|
name = "pysim-simple-server"
|
||||||
version = "3.6.34"
|
version = "3.6.35"
|
||||||
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.34'
|
VERSION = '3.6.35'
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -5033,7 +5033,13 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
self.send_header('Content-Type', 'application/json')
|
self.send_header('Content-Type', 'application/json')
|
||||||
self.send_header('Access-Control-Allow-Origin', '*')
|
self.send_header('Access-Control-Allow-Origin', '*')
|
||||||
self.end_headers()
|
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):
|
def _read_body(self):
|
||||||
length = int(self.headers.get('Content-Length', 0))
|
length = int(self.headers.get('Content-Length', 0))
|
||||||
@@ -5104,7 +5110,10 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
if last_modified:
|
if last_modified:
|
||||||
self.send_header('Last-Modified', last_modified)
|
self.send_header('Last-Modified', last_modified)
|
||||||
self.end_headers()
|
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):
|
def do_GET(self):
|
||||||
if _test_request_blocked(self.path):
|
if _test_request_blocked(self.path):
|
||||||
@@ -5698,6 +5707,7 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
self._send_json({'error': 'no terminal profile configured'}, 400)
|
self._send_json({'error': 'no terminal profile configured'}, 400)
|
||||||
self._log_resp({'error': 'no terminal profile configured'})
|
self._log_resp({'error': 'no terminal profile configured'})
|
||||||
return
|
return
|
||||||
|
_finish_pending_menu(self.server, scc)
|
||||||
sys.stderr.write('RESCUE: re-sending TERMINAL PROFILE\n')
|
sys.stderr.write('RESCUE: re-sending TERMINAL PROFILE\n')
|
||||||
resp = _resend_terminal_profile(self.server, scc)
|
resp = _resend_terminal_profile(self.server, scc)
|
||||||
self._send_json(resp)
|
self._send_json(resp)
|
||||||
@@ -5722,6 +5732,9 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
self._send_json({'error': 'no terminal profile configured'}, 400)
|
self._send_json({'error': 'no terminal profile configured'}, 400)
|
||||||
self._log_resp({'error': 'no terminal profile configured'})
|
self._log_resp({'error': 'no terminal profile configured'})
|
||||||
return
|
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)
|
sys.stderr.write('TERMINAL-PROFILE: re-sending %s\n' % self.server.terminal_profile)
|
||||||
try:
|
try:
|
||||||
resp = _resend_terminal_profile(self.server, scc)
|
resp = _resend_terminal_profile(self.server, scc)
|
||||||
|
|||||||
Reference in New Issue
Block a user