stk: never shadow a paused command with a new menu selection

The stuck-card pattern: after a Back TR the card re-issues the parent menu
as a new SELECT ITEM (91XX -> FETCH, paused, awaiting TR), but the UI's
back/timeout branch ignored that response and showed the cached top menu;
the next item click then sent ENVELOPE(Menu Selection) while the card was
waiting for the TR. The card answers such an ENVELOPE with 9000 (not the
usual 91XX), and menu-select cleared the pending command without a TR,
leaving an unfinished proactive session until reset/equip.

- server: _finish_pending_menu() answers a paused command with a cancel TR
  (0x10) before /api/menu-select sends its ENVELOPE and drains a 91XX
  follow-up, so a new selection can never shadow an unanswered FETCH
- frontend: stkMenuRespond('back'|'timeout') renders the follow-up command
  from the server response (SELECT ITEM / DISPLAY TEXT) and shows the
  cached top menu only when the TR answer carries no command (9000)
- tests: finish-pending cancel TR + chain drain (Python); stkMenuRespond
  back/timeout/cancel/ok rendering (frontend); help/AGENTS updated;
  SW cache v107 -> v108.
This commit is contained in:
2026-09-12 19:34:27 +03:00
parent 1fe5c6347d
commit 45c72d874f
7 changed files with 150 additions and 6 deletions
+1 -1
View File
@@ -412,7 +412,7 @@
<p class="text-sm mb-3">Работа с сессией Card Application Toolkit. Две подвкладки: <strong>&laquo;Телефон&raquo;</strong> (меню STK, STATUS и опрос, подписанные события, журнал проактивных команд) и <strong>&laquo;Конфигурация TR&raquo;</strong> (данные ответов, подставляемые в TERMINAL RESPONSE для проактивных команд).</p> <p class="text-sm mb-3">Работа с сессией Card Application Toolkit. Две подвкладки: <strong>&laquo;Телефон&raquo;</strong> (меню STK, STATUS и опрос, подписанные события, журнал проактивных команд) и <strong>&laquo;Конфигурация TR&raquo;</strong> (данные ответов, подставляемые в TERMINAL RESPONSE для проактивных команд).</p>
<h3 id="stk-menu" class="text-lg font-medium mb-2">6.1 Меню STK</h3> <h3 id="stk-menu" class="text-lg font-medium mb-2">6.1 Меню STK</h3>
<p class="text-sm mb-3">Если карта выдала команду SET UP MENU, вверху этого представления появляется блок &laquo;Меню STK&raquo; с изумрудной кнопкой <strong>STK: &lt;название&gt;</strong>, открывающей оверлей меню (браузер STK-меню карты). Если карта не задала меню, вместо кнопки показывается &laquo;Меню не задано картой&raquo;. Состояние меню обновляется при каждом открытии представления. Интерактивные проактивные команды всегда получают TERMINAL RESPONSE: оверлей ждёт вашего выбора, и если вы не ответили и не нажали <strong>Timeout</strong>, сервер сам отвечает результатом timeout через <code class="font-mono text-sm">--menu-timeout</code> секунд (по умолчанию 60, <code class="font-mono text-sm">0</code> отключает).</p> <p class="text-sm mb-3">Если карта выдала команду SET UP MENU, вверху этого представления появляется блок &laquo;Меню STK&raquo; с изумрудной кнопкой <strong>STK: &lt;название&gt;</strong>, открывающей оверлей меню (браузер STK-меню карты). Если карта не задала меню, вместо кнопки показывается &laquo;Меню не задано картой&raquo;. Состояние меню обновляется при каждом открытии представления. Интерактивные проактивные команды всегда получают TERMINAL RESPONSE: оверлей ждёт вашего выбора, и если вы не ответили и не нажали <strong>Timeout</strong>, сервер сам отвечает результатом timeout через <code class="font-mono text-sm">--menu-timeout</code> секунд (по умолчанию 60, <code class="font-mono text-sm">0</code> отключает). <strong>Назад</strong> и <strong>Timeout</strong> продолжают диалог с картой: если карта в ответ выдаёт следующую проактивную команду (SELECT ITEM или DISPLAY TEXT), панель показывает её; кэшированное верхнее меню появляется только когда карте больше нечего выполнять.</p>
<h3 id="subscribed-events" class="text-lg font-medium mb-2">6.2 Подписанные события (SET UP EVENT LIST)</h3> <h3 id="subscribed-events" class="text-lg font-medium mb-2">6.2 Подписанные события (SET UP EVENT LIST)</h3>
<p class="text-sm mb-2">События, которые отслеживает карта. У каждого события есть кнопка <strong>Отправить</strong>, открывающая форму, специфичную для типа события:</p> <p class="text-sm mb-2">События, которые отслеживает карта. У каждого события есть кнопка <strong>Отправить</strong>, открывающая форму, специфичную для типа события:</p>
+1 -1
View File
@@ -412,7 +412,7 @@
<p class="text-sm mb-3">Interacts with the Card Application Toolkit session. The view has two pills: <strong>Phone</strong> (STK menu, STATUS and polling, subscribed events, proactive command log) and <strong>TR Config</strong> (response data injected into TERMINAL RESPONSEs for proactive commands).</p> <p class="text-sm mb-3">Interacts with the Card Application Toolkit session. The view has two pills: <strong>Phone</strong> (STK menu, STATUS and polling, subscribed events, proactive command log) and <strong>TR Config</strong> (response data injected into TERMINAL RESPONSEs for proactive commands).</p>
<h3 id="stk-menu" class="text-lg font-medium mb-2">6.1 STK menu</h3> <h3 id="stk-menu" class="text-lg font-medium mb-2">6.1 STK menu</h3>
<p class="text-sm mb-3">When the card has issued a SET UP MENU command, a &ldquo;STK menu&rdquo; block appears at the top of this view with an emerald <strong>STK: &lt;title&gt;</strong> button that opens the menu overlay (same as the card&rsquo;s STK menu browser). If the card has not set up a menu, the block shows &ldquo;No menu set by the card&rdquo; instead. The menu state is refreshed each time the view is opened. User-interactive proactive commands always get a TERMINAL RESPONSE: the overlay pauses for your choice, and if you neither answer nor press <strong>Timeout</strong>, the server answers with a timeout result after the <code class="font-mono text-sm">--menu-timeout</code> seconds (default 60, <code class="font-mono text-sm">0</code> disables).</p> <p class="text-sm mb-3">When the card has issued a SET UP MENU command, a &ldquo;STK menu&rdquo; block appears at the top of this view with an emerald <strong>STK: &lt;title&gt;</strong> button that opens the menu overlay (same as the card&rsquo;s STK menu browser). If the card has not set up a menu, the block shows &ldquo;No menu set by the card&rdquo; instead. The menu state is refreshed each time the view is opened. User-interactive proactive commands always get a TERMINAL RESPONSE: the overlay pauses for your choice, and if you neither answer nor press <strong>Timeout</strong>, the server answers with a timeout result after the <code class="font-mono text-sm">--menu-timeout</code> seconds (default 60, <code class="font-mono text-sm">0</code> disables). <strong>Back</strong> and <strong>Timeout</strong> keep the dialogue with the card going: when the card replies with a further proactive command (SELECT ITEM or DISPLAY TEXT) the panel shows it; the cached top menu appears only when the card has nothing more to execute.</p>
<h3 id="subscribed-events" class="text-lg font-medium mb-2">6.2 Subscribed events (SET UP EVENT LIST)</h3> <h3 id="subscribed-events" class="text-lg font-medium mb-2">6.2 Subscribed events (SET UP EVENT LIST)</h3>
<p class="text-sm mb-2">The events the card monitors. Each event has a <strong>Send</strong> button that opens a form specific to the event type:</p> <p class="text-sm mb-2">The events the card monitors. Each event has a <strong>Send</strong> button that opens a form specific to the event type:</p>
+4
View File
@@ -5486,9 +5486,13 @@ 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 (result === 'cancel' || result === 'back' || result === 'timeout') { if (result === 'cancel' || result === 'back' || result === 'timeout') {
if (data && (data.type === 'select_item' || data.type === 'display_text')) {
stkMenuHandleResponse(data);
} else {
stkMenuRenderItems(); stkMenuRenderItems();
document.getElementById('stk-menu-buttons').classList.add('hidden'); document.getElementById('stk-menu-buttons').classList.add('hidden');
document.getElementById('stk-back-btn').style.display = 'none'; document.getElementById('stk-back-btn').style.display = 'none';
}
return; return;
} }
stkMenuHandleResponse(data); stkMenuHandleResponse(data);
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'otaman-v107'; const CACHE = 'otaman-v108';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+85
View File
@@ -0,0 +1,85 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name, asyncFn) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
}
let code = extractFunc(html, 'stkMenuRespond', true) + '\n';
code += 'globalThis.esc = s => s;\n';
eval(code);
function setup(response) {
const calls = { handled: null, rendered: 0 };
globalThis.pysimFetch = async () => response;
globalThis.stkMenuHandleResponse = d => { calls.handled = d; };
globalThis.stkMenuRenderItems = () => { calls.rendered++; };
const btns = { classList: { add: () => {} } };
const back = { style: {} };
globalThis.document = {
getElementById: id => (id === 'stk-menu-buttons' ? btns : id === 'stk-back-btn' ? back : { innerHTML: '' }),
};
return calls;
}
test('back with a fetched SELECT ITEM continues the card dialogue', async () => {
const data = { type: 'select_item', items: [{ id: 1, text: 'Info' }] };
const calls = setup(data);
await stkMenuRespond('back');
assert.strictEqual(calls.handled, data);
assert.strictEqual(calls.rendered, 0);
});
test('back with a fetched DISPLAY TEXT shows it', async () => {
const data = { type: 'display_text', text: 'hello' };
const calls = setup(data);
await stkMenuRespond('back');
assert.strictEqual(calls.handled, data);
assert.strictEqual(calls.rendered, 0);
});
test('timeout with a fetched SELECT ITEM continues the card dialogue', async () => {
const data = { type: 'select_item', items: [] };
const calls = setup(data);
await stkMenuRespond('timeout');
assert.strictEqual(calls.handled, data);
assert.strictEqual(calls.rendered, 0);
});
test('back answered with SW 9000 falls back to the cached top menu', async () => {
const calls = setup({ type: 'done', sw: '9000' });
await stkMenuRespond('back');
assert.strictEqual(calls.handled, null);
assert.strictEqual(calls.rendered, 1);
});
test('cancel falls back to the cached top menu', async () => {
const calls = setup({ sw: '9000' });
await stkMenuRespond('cancel');
assert.strictEqual(calls.handled, null);
assert.strictEqual(calls.rendered, 1);
});
test('ok navigates with the server response', async () => {
const data = { type: 'select_item', items: [{ id: 1, text: 'x' }] };
const calls = setup(data);
await stkMenuRespond('ok');
assert.strictEqual(calls.handled, data);
assert.strictEqual(calls.rendered, 0);
});
+16
View File
@@ -1601,6 +1601,21 @@ def _menu_send_response(server, result, item_id=None):
return resp, 200 return resp, 200
def _finish_pending_menu(server, scc):
"""A new menu selection must never shadow a FETCHed command that awaits its
TERMINAL RESPONSE: answer it with a cancel TR (0x10) first, then drain any
follow-up proactive command so the card is ready for the new selection."""
pd = server.stk_pending
if not pd:
return
sys.stderr.write('MENU-SELECT: finishing pending cmd=%02x type=%02x with cancel TR\n'
% (pd['cmd_num'], pd['cmd_type']))
resp, _ = _menu_send_response(server, 'cancel', None)
sw = (resp or {}).get('sw', '')
if sw.startswith('91'):
_handle_proactive_chain(scc, sw)
class PysimHandler(BaseHTTPRequestHandler): class PysimHandler(BaseHTTPRequestHandler):
def _send_json(self, data, status=200): def _send_json(self, data, status=200):
self.send_response(status) self.send_response(status)
@@ -2179,6 +2194,7 @@ class PysimHandler(BaseHTTPRequestHandler):
return return
body = self._read_body() body = self._read_body()
self._log_req(body) self._log_req(body)
_finish_pending_menu(self.server, scc)
item_id = body.get('item_id', 0) item_id = body.get('item_id', 0)
if not isinstance(item_id, int): if not isinstance(item_id, int):
item_id = int(item_id) item_id = int(item_id)
+39
View File
@@ -87,3 +87,42 @@ class TestMenuSendResponse(unittest.TestCase):
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()
class TestFinishPendingMenu(unittest.TestCase):
def make_server(self):
return types.SimpleNamespace(
stk_pending={'type': 'select_item', 'cmd_num': 1, 'cmd_type': 0x24,
'dev_src': 0x81, 'dev_dst': 0x83, 'items': []},
menu_active=True, scc=None)
def make_scc(self, sent, sw='9000'):
return types.SimpleNamespace(
cat_cla='80',
_tp=types.SimpleNamespace(send_apdu=lambda h: (sent.append(h) or ('', sw))))
def test_no_pending_is_noop(self):
sent = []
S._finish_pending_menu(types.SimpleNamespace(stk_pending=None), self.make_scc(sent))
self.assertEqual(sent, [])
def test_pending_finished_with_cancel_tr(self):
server = self.make_server()
sent = []
scc = self.make_scc(sent)
server.scc = scc
S._finish_pending_menu(server, scc)
self.assertEqual(len(sent), 1)
tr = sent[0]
self.assertTrue(tr.startswith('801400000d'), tr)
self.assertIn('83021000', tr) # general result 0x10 = cancel
self.assertIsNone(server.stk_pending)
self.assertFalse(server.menu_active)
def test_91xx_answer_drains_chain(self):
server = self.make_server()
scc = self.make_scc([], sw='9120')
server.scc = scc
with mock.patch.object(S, '_handle_proactive_chain') as chain:
S._finish_pending_menu(server, scc)
chain.assert_called_once_with(scc, '9120')