fix: recover from a card swap without a server restart (v3.5.2)
Equip was broken after a card swap: auto-equip (and manual Equip) failed with "Failed to transmit with protocol T0. Card was removed. (0x80100069)" until the server was restarted. Chain (v3.1.2 regression): - _handle_card_disconnect() cleared server.card/scc but not pySim's app.card/app.rs/app.lchan, so the removed card - and with it the old PC/SC link and its exclusive card handle - stayed referenced; handlers using app.rs (e.g. the PWA's /api/tree poll) kept transmitting over the dead card. - those errors carry hresult=0x80100069 (SCARD_W_REMOVED_CARD), but _is_pcsc_error() treated any PC/SC error as a dead service and set _TRANSPORT_STALE; the next auto-equip then called _ensure_transport(), which built a second PcscSimLink while the old one was still connected - the new link inherited the removed card's handle and failed on its first APDU. No retry existed, so every later equip repeated the failure. Fixes: - _is_transport_fatal(): only service/context hresults rebuild the transport (E_NO_SERVICE, E_SERVICE_STOPPED, E_NO_READERS_AVAILABLE, E_INVALID_HANDLE, ...); card-level states (W_REMOVED_CARD, E_NO_SMARTCARD, W_RESET_CARD, W_UNRESPONSIVE_CARD, W_UNPOWERED_CARD) reconnect on the existing link. All 8 disconnect call sites pass the new verdict. - _clear_app_card_state(): unequip through pySim's own equip(None, None) before clearing app.card/app.rs/app.lchan. Nulling them alone would make the next equip abort with "CommandSet ... is already installed" (PysimApp.equip() unregisters the previous profile's command sets from self.rs), which left /api/tree broken after a swap. A failed auto-equip attempt unequips the half-initialized shell as well. Handlers now answer "no card" instead of transmitting over the dead card, and the old link becomes collectable. - _ensure_transport(): disconnects the old link before building the new one (restoring it if the factory fails) - the rebuild path is now safe for the real pcscd-restart case. - auto-equip: _auto_equip_attempt()/_auto_equip_attempts() retry up to 3 times, 1 s apart (fatal failures mark the transport so the retry rebuilds); the watchdog re-arms it (_auto_equip_rearm) every 5 s while a card is present and the session is down, with exponential backoff to 60 s for a card that cannot be initialized at all. - fastinit.init_card_fast(): also retries once after a PC/SC link error (CardConnectionException/NoCardException), not only after SW mismatches. Tests: transport-fatal classification, app-state clearing, shell unequip, old-link release, auto-equip retry/backoff/re-arm, fastinit link retry. 547 frontend / 410 Python green; version 3.5.2; sw cache simple-v254.
This commit is contained in:
@@ -3,7 +3,9 @@
|
||||
transport recreation after a service failure (pcscd restart)."""
|
||||
|
||||
import unittest
|
||||
from io import StringIO
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
from pysim_simple_server import server
|
||||
|
||||
@@ -21,6 +23,27 @@ class PcscErrorTests(unittest.TestCase):
|
||||
self.assertFalse(server._is_pcsc_error(Exception()))
|
||||
|
||||
|
||||
class TransportFatalTests(unittest.TestCase):
|
||||
@staticmethod
|
||||
def _exc(hresult):
|
||||
exc = RuntimeError('pcsc')
|
||||
exc.hresult = hresult
|
||||
return exc
|
||||
|
||||
def test_card_level_errors_do_not_rebuild_the_transport(self):
|
||||
# A normal card swap (SCARD_W_REMOVED_CARD) is recovered by a plain
|
||||
# reconnect on the existing link - it must not trigger a rebuild.
|
||||
for hr in (0x80100069, 0x8010000C, 0x80100068, 0x80100066, 0x80100067):
|
||||
self.assertFalse(server._is_transport_fatal(self._exc(hr)), hex(hr))
|
||||
|
||||
def test_service_errors_rebuild_the_transport(self):
|
||||
for hr in (0x8010001D, 0x8010001E, 0x8010002E, 0x80100003):
|
||||
self.assertTrue(server._is_transport_fatal(self._exc(hr)), hex(hr))
|
||||
|
||||
def test_non_pcsc_errors_are_not_transport_fatal(self):
|
||||
self.assertFalse(server._is_transport_fatal(RuntimeError('SW match failed')))
|
||||
|
||||
|
||||
class WatchdogTests(unittest.TestCase):
|
||||
def test_alive_monitor_is_left_alone(self):
|
||||
calls = []
|
||||
@@ -77,6 +100,26 @@ class EnsureTransportTests(unittest.TestCase):
|
||||
self.assertTrue(server._TRANSPORT_STALE)
|
||||
self.assertEqual(srv.sl, 'old-sl')
|
||||
|
||||
def test_old_link_is_released_before_the_rebuild(self):
|
||||
released = []
|
||||
|
||||
class FakeLink:
|
||||
def disconnect(self):
|
||||
released.append(True)
|
||||
|
||||
old = FakeLink()
|
||||
app = SimpleNamespace(sl=old, card='card', rs='rs', lchan='lchan')
|
||||
srv = SimpleNamespace(sl=old, scc='scc', card='card', app=app,
|
||||
transport_factory=lambda: 'new-sl')
|
||||
server._TRANSPORT_STALE = True
|
||||
self.assertTrue(server._ensure_transport(srv))
|
||||
self.assertEqual(released, [True])
|
||||
self.assertEqual(srv.sl, 'new-sl')
|
||||
self.assertEqual(app.sl, 'new-sl')
|
||||
self.assertIsNone(app.card)
|
||||
self.assertIsNone(app.rs)
|
||||
self.assertIsNone(app.lchan)
|
||||
|
||||
def test_no_factory_clears_the_flag(self):
|
||||
srv = SimpleNamespace(sl='old-sl', app=None, transport_factory=None)
|
||||
server._TRANSPORT_STALE = True
|
||||
@@ -103,3 +146,158 @@ class DisconnectTests(unittest.TestCase):
|
||||
def test_plain_disconnect_leaves_the_transport_usable(self):
|
||||
server._handle_card_disconnect()
|
||||
self.assertFalse(server._TRANSPORT_STALE)
|
||||
|
||||
def test_disconnect_unequips_the_shell_first(self):
|
||||
# PysimApp.equip(None, None) unregisters the old profile's command
|
||||
# sets; without it the next equip fails with "CommandSet ... is
|
||||
# already installed" and the file tree breaks.
|
||||
calls = []
|
||||
app = SimpleNamespace(rs='rs', card='card', lchan='lchan',
|
||||
stdout=StringIO(),
|
||||
equip=lambda c, r: calls.append((c, r)))
|
||||
srv = SimpleNamespace(app=app, card='card', scc='scc', stk_pending=None,
|
||||
menu_active=False, event_list=None, sim_menu=None,
|
||||
iccid=None, net_state=None, equipping=False, card_session=1)
|
||||
saved = server._server_ref
|
||||
server._server_ref = srv
|
||||
try:
|
||||
server._handle_card_disconnect()
|
||||
finally:
|
||||
server._server_ref = saved
|
||||
self.assertEqual(calls, [(None, None)])
|
||||
self.assertIsNone(app.card)
|
||||
self.assertIsNone(app.rs)
|
||||
self.assertIsNone(app.lchan)
|
||||
|
||||
def test_disconnect_clears_the_app_card_state(self):
|
||||
# The dead card must not stay reachable through app.card/app.rs:
|
||||
# handlers using app.rs kept transmitting over the removed card and
|
||||
# the old PC/SC link stayed connected (the v3.5.1 auto-equip bug).
|
||||
app = SimpleNamespace(card='card', rs='rs', lchan='lchan')
|
||||
srv = SimpleNamespace(app=app, card='card', scc='scc', stk_pending='x',
|
||||
menu_active=True, event_list='e', sim_menu='m',
|
||||
iccid='123', net_state='n', equipping=True, card_session=5)
|
||||
saved = server._server_ref
|
||||
server._server_ref = srv
|
||||
try:
|
||||
server._handle_card_disconnect()
|
||||
finally:
|
||||
server._server_ref = saved
|
||||
self.assertIsNone(app.card)
|
||||
self.assertIsNone(app.rs)
|
||||
self.assertIsNone(app.lchan)
|
||||
self.assertIsNone(srv.card)
|
||||
self.assertIsNone(srv.scc)
|
||||
|
||||
|
||||
class AutoEquipTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.saved = (server._server_ref, server._CARD_CONNECTED, server._TRANSPORT_STALE,
|
||||
server._AUTO_EQUIP, server._AUTO_EQUIP_BUSY, server._AUTO_EQUIP_LAST,
|
||||
server._AUTO_EQUIP_BACKOFF)
|
||||
server._server_ref = None
|
||||
server._CARD_CONNECTED = False
|
||||
server._TRANSPORT_STALE = False
|
||||
server._AUTO_EQUIP = True
|
||||
server._AUTO_EQUIP_BUSY = False
|
||||
server._AUTO_EQUIP_LAST = 0.0
|
||||
server._AUTO_EQUIP_BACKOFF = server._AUTO_EQUIP_REARM_DELAY
|
||||
|
||||
def tearDown(self):
|
||||
(server._server_ref, server._CARD_CONNECTED, server._TRANSPORT_STALE,
|
||||
server._AUTO_EQUIP, server._AUTO_EQUIP_BUSY, server._AUTO_EQUIP_LAST,
|
||||
server._AUTO_EQUIP_BACKOFF) = self.saved
|
||||
|
||||
@staticmethod
|
||||
def make_server(onecmd):
|
||||
app = SimpleNamespace(stdout=StringIO(), card=None)
|
||||
app.onecmd_plus_hooks = onecmd
|
||||
return SimpleNamespace(app=app, card=None, scc=None, card_present=True,
|
||||
equipping=False, terminal_profile='tp', card_session=1)
|
||||
|
||||
def test_retries_after_a_transient_card_level_failure(self):
|
||||
state = {'calls': 0}
|
||||
srv = self.make_server(None)
|
||||
|
||||
def onecmd(cmd):
|
||||
state['calls'] += 1
|
||||
if state['calls'] == 1:
|
||||
raise RuntimeError('Failed to transmit with protocol T0. Card was removed.')
|
||||
srv.app.card = SimpleNamespace(_scc='scc')
|
||||
|
||||
srv.app.onecmd_plus_hooks = onecmd
|
||||
applied = []
|
||||
with mock.patch.object(server, '_ensure_transport', lambda s: True), \
|
||||
mock.patch.object(server, '_apply_equipped_card', lambda s: applied.append(s)):
|
||||
self.assertTrue(server._auto_equip_attempts(srv, 3, lambda s: None))
|
||||
self.assertEqual(state['calls'], 2)
|
||||
self.assertEqual(applied, [srv])
|
||||
self.assertFalse(server._TRANSPORT_STALE)
|
||||
|
||||
def test_card_level_failure_keeps_the_transport(self):
|
||||
srv = self.make_server(None)
|
||||
|
||||
def onecmd(cmd):
|
||||
exc = RuntimeError('Card was removed.')
|
||||
exc.hresult = 0x80100069
|
||||
raise exc
|
||||
|
||||
srv.app.onecmd_plus_hooks = onecmd
|
||||
with mock.patch.object(server, '_ensure_transport', lambda s: True):
|
||||
self.assertFalse(server._auto_equip_attempt(srv))
|
||||
self.assertFalse(server._TRANSPORT_STALE)
|
||||
|
||||
def test_transport_fatal_failure_marks_the_transport(self):
|
||||
srv = self.make_server(None)
|
||||
|
||||
def onecmd(cmd):
|
||||
exc = RuntimeError('service not available')
|
||||
exc.hresult = 0x8010001D
|
||||
raise exc
|
||||
|
||||
srv.app.onecmd_plus_hooks = onecmd
|
||||
with mock.patch.object(server, '_ensure_transport', lambda s: True):
|
||||
self.assertFalse(server._auto_equip_attempt(srv))
|
||||
self.assertTrue(server._TRANSPORT_STALE)
|
||||
|
||||
def test_rearm_triggers_with_cooldown(self):
|
||||
calls = []
|
||||
server._server_ref = SimpleNamespace(card_present=True, equipping=False)
|
||||
with mock.patch.object(server, '_auto_equip_trigger',
|
||||
lambda: calls.append(1) or True):
|
||||
self.assertTrue(server._auto_equip_rearm(now=100.0))
|
||||
self.assertFalse(server._auto_equip_rearm(now=100.5))
|
||||
self.assertTrue(server._auto_equip_rearm(now=106.0))
|
||||
self.assertEqual(len(calls), 2)
|
||||
|
||||
def test_failed_worker_backs_off_the_rearm(self):
|
||||
state = {'calls': 0}
|
||||
srv = self.make_server(None)
|
||||
|
||||
def onecmd(cmd):
|
||||
state['calls'] += 1
|
||||
raise RuntimeError('card cannot be initialized')
|
||||
|
||||
srv.app.onecmd_plus_hooks = onecmd
|
||||
server._server_ref = srv
|
||||
with mock.patch.object(server, '_ensure_transport', lambda s: True), \
|
||||
mock.patch.object(server, '_apply_equipped_card', lambda s: None), \
|
||||
mock.patch.object(server, '_AUTO_EQUIP_ATTEMPTS', 1):
|
||||
server._auto_equip_worker()
|
||||
self.assertEqual(state['calls'], 1)
|
||||
self.assertGreater(server._AUTO_EQUIP_BACKOFF, server._AUTO_EQUIP_REARM_DELAY)
|
||||
|
||||
def test_rearm_needs_a_present_card_and_no_session(self):
|
||||
calls = []
|
||||
server._server_ref = SimpleNamespace(card_present=True, equipping=False)
|
||||
with mock.patch.object(server, '_auto_equip_trigger',
|
||||
lambda: calls.append(1) or True):
|
||||
server._CARD_CONNECTED = True
|
||||
self.assertFalse(server._auto_equip_rearm(now=100.0))
|
||||
server._CARD_CONNECTED = False
|
||||
server._server_ref.card_present = False
|
||||
self.assertFalse(server._auto_equip_rearm(now=100.0))
|
||||
server._server_ref.card_present = True
|
||||
server._server_ref.equipping = True
|
||||
self.assertFalse(server._auto_equip_rearm(now=100.0))
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
@@ -214,6 +214,30 @@ class TestInitCardFastRetry(unittest.TestCase):
|
||||
self.assertEqual((rs, card), ('rs', 'card'))
|
||||
|
||||
|
||||
class TestInitCardFastLinkRetry(unittest.TestCase):
|
||||
def test_link_error_retries_after_reconnect(self):
|
||||
from smartcard.Exceptions import CardConnectionException
|
||||
calls = {'once': 0, 'disconnects': 0}
|
||||
|
||||
class FakeLink:
|
||||
def disconnect(self):
|
||||
calls['disconnects'] += 1
|
||||
|
||||
def once(sl, skip, wait):
|
||||
calls['once'] += 1
|
||||
if calls['once'] == 1:
|
||||
raise CardConnectionException(
|
||||
'Failed to transmit with protocol T0. Card was removed.',
|
||||
hresult=0x80100069)
|
||||
return ('rs', 'card')
|
||||
|
||||
with mock.patch.object(fastinit, '_init_card_once', side_effect=once):
|
||||
rs, card = fastinit.init_card_fast(FakeLink(), wait=True)
|
||||
self.assertEqual(calls['once'], 2)
|
||||
self.assertEqual(calls['disconnects'], 1)
|
||||
self.assertEqual((rs, card), ('rs', 'card'))
|
||||
|
||||
|
||||
class TestDoEquipFastFailure(unittest.TestCase):
|
||||
def test_failed_equip_keeps_previous_state(self):
|
||||
calls = []
|
||||
|
||||
Reference in New Issue
Block a user