v3.25.1: the run-ownership fix for the proactive auto-handling
A card that re-queues a command immediately after each accepted TR (live 2026-10-11: the EP BAP card re-issued the same PROVIDE LOCAL INFORMATION 1100 times) turned the background poll's FETCH/TR chain into an unbounded loop that held _CARD_LOCK forever - every card operation, a test run's start included, blocked behind it. The chain now yields: - _handle_proactive_chain aborts before the next FETCH while a test run owns the card (_TEST_RUNNING) - the pending command stays for the script - and takes max_rounds; the background poll passes _POLL_CHAIN_MAX = 10, so a chatty card cannot hold the lock. - _do_status_poll re-arms on its run skip (a heartbeat that never fetches); _timer_expired and the BIP Data-available event wait during a run; _bip_flush_channel_events stays quiet too. - _sim_resume_poll() resumes polling at every run end - the two worker bodies, their exception paths and the spawn failure. Also the TR command-details qualifier echo (TS 102 223 6.8.1: the object shall be identical to the command's): _build_tr, testscript.build_tr and the interactive _menu_send_response echoed a hardcoded 00 - harmless for the loop's qual-00 PLI, wrong for any other qualifier. The menu pendings now carry cmd_qual (parsed in the on_fetch handler). Tests: the chain bound/abort (a fake card re-queueing a command), the poll/timer guards, the resume wiring, the qualifier echo in both builders. 883 frontend + 865 Python; sw cache simple-v425.
This commit is contained in:
@@ -863,6 +863,20 @@ class TestProactiveDecode(unittest.TestCase):
|
||||
tr = _build_tr(None, 1, 0x26, 0x81, 0x82, 0x00)
|
||||
self.assertEqual(tr.hex(), '81030126008202828103010093055210011000')
|
||||
|
||||
def test_build_tr_echoes_the_command_qualifier(self):
|
||||
# TS 102 223 6.8.1: the TR's command details shall be identical to
|
||||
# the command's (including the qualifier); without one they stay 00
|
||||
self.assertTrue(_build_tr(None, 1, 0x26, 0x81, 0x82, 0x06)
|
||||
.hex().startswith('8103012606'))
|
||||
self.assertTrue(_build_tr(None, 1, 0x03, 0x81, 0x82, None)
|
||||
.hex().startswith('8103010300'))
|
||||
# the runner's builder echoes the same way
|
||||
from pysim_simple_server import testscript as T
|
||||
self.assertTrue(T.build_tr(1, 0x26, 0x82, 0x81, {}, cmd_qual=0x06)
|
||||
.hex().startswith('8103012606'))
|
||||
self.assertTrue(T.build_tr(1, 0x26, 0x82, 0x81, {})
|
||||
.hex().startswith('8103012600'))
|
||||
|
||||
def test_build_tr_poll_interval_duration_after_result(self):
|
||||
import pysim_simple_server.server as srv
|
||||
tr = _build_tr(None, 1, 0x03, 0x81, 0x82, None)
|
||||
@@ -884,11 +898,12 @@ class TestProactiveDecode(unittest.TestCase):
|
||||
tr_west = _build_tr(None, 1, 0x26, 0x81, 0x82, 0x03, west)
|
||||
finally:
|
||||
srv._PLI_DATA[0x03] = saved
|
||||
# the command details echo the qualifier (TS 102 223 6.8.1)
|
||||
self.assertEqual(tr_east.hex(),
|
||||
'810301260082028281030100260762900312550021')
|
||||
'810301260382028281030100260762900312550021')
|
||||
# negative zone: the sign bit of the time zone byte (0x08)
|
||||
self.assertEqual(tr_west.hex(),
|
||||
'810301260082028281030100260762105080709009')
|
||||
'810301260382028281030100260762105080709009')
|
||||
|
||||
def test_build_tr_pli_dict_entry_overrides_host_clock(self):
|
||||
import pysim_simple_server.server as srv
|
||||
@@ -900,7 +915,7 @@ class TestProactiveDecode(unittest.TestCase):
|
||||
datetime(2026, 9, 30, 21, 55, tzinfo=timezone.utc))
|
||||
finally:
|
||||
srv._PLI_DATA[0x03] = saved
|
||||
self.assertEqual(tr.hex(), '810301260082028281030100130752f01000ff0001')
|
||||
self.assertEqual(tr.hex(), '810301260382028281030100130752f01000ff0001')
|
||||
|
||||
def test_build_tr_pli_empty_other_qualifier_sends_no_data(self):
|
||||
# a qualifier with neither an override nor a built-in default (02
|
||||
@@ -913,7 +928,7 @@ class TestProactiveDecode(unittest.TestCase):
|
||||
data = srv._pli_data_hex(0x02)
|
||||
finally:
|
||||
srv._PLI_DATA[0x02] = saved
|
||||
self.assertEqual(tr.hex(), '810301260082028281030100')
|
||||
self.assertEqual(tr.hex(), '810301260282028281030100')
|
||||
self.assertEqual(data, '')
|
||||
|
||||
def test_pli_defaults_cover_the_common_qualifiers(self):
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"""Tests for the background STATUS polling interval semantics."""
|
||||
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
@@ -156,5 +157,95 @@ class TestCardDrivenPolling(unittest.TestCase):
|
||||
self.assertEqual(fields[0]['value'], '30 s')
|
||||
|
||||
|
||||
class FakeChattyScc:
|
||||
"""A card that re-queues the same PROVIDE LOCAL INFORMATION immediately
|
||||
after every accepted TERMINAL RESPONSE - the live card's loop (v3.25.1)."""
|
||||
|
||||
cat_cla = '80'
|
||||
|
||||
def __init__(self):
|
||||
self.sent = []
|
||||
self._tp = types.SimpleNamespace(send_apdu=self._send)
|
||||
|
||||
def _send(self, hexstr):
|
||||
self.sent.append(hexstr)
|
||||
low = hexstr.lower()
|
||||
if low.startswith('8012'): # FETCH: the PLI is pending
|
||||
return ('D009810301260082028182', '9000')
|
||||
if low.startswith('8014'): # TR: accepted
|
||||
return ('', '9000')
|
||||
if low.startswith('80f2'): # STATUS: re-announced
|
||||
return ('', '910b')
|
||||
return ('', '9000')
|
||||
|
||||
|
||||
class TestProactiveChainOwnership(unittest.TestCase):
|
||||
"""The auto chain must yield: a bounded number of answers per poll cycle
|
||||
and no fetching at all while a test run owns the card (v3.25.1 - the live
|
||||
1100-command loop held _CARD_LOCK and blocked a run's start)."""
|
||||
|
||||
def setUp(self):
|
||||
self.saved = (S._TEST_RUNNING, S._server_ref, S._CARD_CONNECTED)
|
||||
S._TEST_RUNNING = False
|
||||
|
||||
def tearDown(self):
|
||||
S._TEST_RUNNING, S._server_ref, S._CARD_CONNECTED = self.saved
|
||||
|
||||
def _fetches(self, scc):
|
||||
return [a for a in scc.sent if a.lower().startswith('8012')]
|
||||
|
||||
def test_the_poll_chain_yields_after_the_bound(self):
|
||||
scc = FakeChattyScc()
|
||||
S._handle_proactive_chain(scc, '910b', max_rounds=3)
|
||||
self.assertEqual(len(self._fetches(scc)), 3)
|
||||
|
||||
def test_the_chain_stops_when_a_run_owns_the_card(self):
|
||||
scc = FakeChattyScc()
|
||||
S._TEST_RUNNING = True
|
||||
S._handle_proactive_chain(scc, '910b')
|
||||
self.assertEqual(scc.sent, [])
|
||||
|
||||
def test_the_poll_never_fetches_during_a_run_and_keeps_ticking(self):
|
||||
scc = FakeChattyScc()
|
||||
S._TEST_RUNNING = True
|
||||
S._server_ref = types.SimpleNamespace(scc=scc)
|
||||
S._POLL_ENABLED = True
|
||||
S._POLL_DISABLED_BY_CARD = False
|
||||
try:
|
||||
with mock.patch.object(S, '_reset_poll_timer') as timer:
|
||||
S._do_status_poll()
|
||||
timer.assert_called_once()
|
||||
finally:
|
||||
S._POLL_ENABLED = False
|
||||
self.assertEqual(scc.sent, [])
|
||||
|
||||
def test_the_timer_expiration_waits_for_a_run(self):
|
||||
S._TEST_RUNNING = True
|
||||
S._CARD_CONNECTED = True
|
||||
S._server_ref = types.SimpleNamespace()
|
||||
with mock.patch.object(S, '_timer_start') as start:
|
||||
S._timer_expired(1, 60)
|
||||
start.assert_called_once_with(1, 5)
|
||||
|
||||
def test_sim_resume_poll_rearms_only_when_enabled(self):
|
||||
S._POLL_ENABLED = True
|
||||
S._POLL_DISABLED_BY_CARD = False
|
||||
with mock.patch.object(S, '_reset_poll_timer') as timer:
|
||||
S._sim_resume_poll()
|
||||
timer.assert_called_once()
|
||||
S._POLL_DISABLED_BY_CARD = True
|
||||
with mock.patch.object(S, '_reset_poll_timer') as timer:
|
||||
S._sim_resume_poll()
|
||||
timer.assert_not_called()
|
||||
|
||||
def test_every_run_end_resumes_the_poll(self):
|
||||
# the two worker bodies, their two exception paths and the spawn
|
||||
# failure all call the helper
|
||||
import inspect
|
||||
src = inspect.getsource(S)
|
||||
self.assertEqual(src.count('\n _sim_resume_poll()'), 2)
|
||||
self.assertEqual(src.count('\n _sim_resume_poll()'), 3)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user