From b87b3905eee53e25138881d134c6f1619d490745 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, 22 Sep 2026 22:01:28 +0300 Subject: [PATCH] fix: recover from a PC/SC service failure without a server restart (v3.1.2) After pcscd restarted (or the reader re-enumerated) the server stayed permanently cardless: - pyscard's presence-monitor thread stops itself on SCARD_E_NO_SERVICE and pyscard never starts it again, so card insertions were no longer noticed and auto-equip was dead; - PCSCCardConnection.connect() reuses the context handle captured when the reader was opened, so the old connection could not be revived and every APDU kept failing with 'Service not available'. - _start_card_watchdog(): a daemon that every 5 s checks whether CardMonitor().rmthread.instance is alive and recreates the stopped thread (CardMonitoringThread.instance = None + a fresh rmthread); the new thread reports already-present cards as inserted, which triggers auto-equip. start_card_monitor() starts the watchdog. - _is_pcsc_error(): pyscard exceptions carry an hresult, card-level errors do not; the disconnect paths (AUTO-STATUS, status poll, timer expiration, net-sim, tree, event send, OTA send) pass the verdict to _handle_card_disconnect(stale=...), which marks the transport stale. - _ensure_transport(): builds a fresh transport via server.transport_factory (installed by __main__ as mod.init_reader(opts, **tracer_kwargs)) and installs it into app.sl/server.sl; called by the auto-equip worker, the /api/command equip branch and _esim_reinit. - tests: watchdog tick, transport recreation (fresh/stale/failure/no factory), PC/SC classification, stale disconnect flag. - docs: AGENTS card-behavior section, README troubleshooting (EN/RU); version 3.1.2, sw.js simple-v237. --- README.md | 1 + README_RUS.md | 1 + frontend/index.html | 2 +- frontend/sw.js | 2 +- pyproject.toml | 2 +- pysim_simple_server/__main__.py | 5 ++ pysim_simple_server/server.py | 154 ++++++++++++++++++++++++++++---- tests/test_card_recovery.py | 105 ++++++++++++++++++++++ 8 files changed, 253 insertions(+), 19 deletions(-) create mode 100644 tests/test_card_recovery.py diff --git a/README.md b/README.md index 4e25e9d..d36d1f7 100644 --- a/README.md +++ b/README.md @@ -700,6 +700,7 @@ pysim-simple-server --http-port 8080 - **"Failed to establish context: Access denied"** — `pcscd` isn't running or the user lacks permission: `sudo systemctl enable --now pcscd && sudo usermod -a -G pcscd $USER`. - **"device file /dev/ttyUSB0 does not exist"** — no serial reader; connect a USB reader or pass `-d` explicitly. The server still starts without a reader. +- **"Service not available" (0x8010001D) / reader gone after a `pcscd` restart** — the server recovers by itself: a watchdog revives pyscard's presence monitor and the next equip (automatic after re-insertion, or the **Equip** button) recreates the PC/SC transport. No server restart needed. ### API reference diff --git a/README_RUS.md b/README_RUS.md index 01dff16..29a29e6 100644 --- a/README_RUS.md +++ b/README_RUS.md @@ -683,6 +683,7 @@ pysim-simple-server --http-port 8080 - **"Failed to establish context: Access denied"** — `pcscd` не запущен или нет прав: `sudo systemctl enable --now pcscd && sudo usermod -a -G pcscd $USER`. - **"device file /dev/ttyUSB0 does not exist"** — нет serial-ридера; подключите USB-ридер или укажите `-d`. Сервер всё равно стартует без ридера. +- **"Service not available" (0x8010001D) / ридер пропал после перезапуска `pcscd`** — сервер восстанавливается сам: сторож перезапускает монитор присутствия pyscard, а следующий equip (автоматически после повторной вставки карты или кнопкой **Equip**) пересоздаёт транспорт PC/SC. Перезапуск сервера не нужен. ### Справочник API diff --git a/frontend/index.html b/frontend/index.html index d083885..543c837 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1436,7 +1436,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.1.1'; +const SIMPLE_VERSION = '3.1.2'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== Tab switching ===== diff --git a/frontend/sw.js b/frontend/sw.js index 626ffb8..60ffa3d 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v236'; +const CACHE = 'simple-v237'; const URLS = [ 'index.html', 'help.html', diff --git a/pyproject.toml b/pyproject.toml index 324c9be..61bcd78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "3.1.1" +version = "3.1.2" 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/__main__.py b/pysim_simple_server/__main__.py index 79dfaa2..369e0e4 100644 --- a/pysim_simple_server/__main__.py +++ b/pysim_simple_server/__main__.py @@ -253,6 +253,11 @@ def main(): server.card = card server.rs = rs server.app = app + # PC/SC recovery: recreate the transport after a service failure (pcscd + # restart / reader re-enumeration); _ensure_transport() calls this. + def _new_transport(): + return mod.init_reader(opts, **kwargs) + server.transport_factory = _new_transport server.sms_oa = opts.sms_oa server.sms_sc = opts.sms_sm_sc server.log_requests = opts.log_requests diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py index d98f9d1..3c761e9 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -28,7 +28,7 @@ from osmocom.tlv import BER_TLV_IE from osmocom.utils import rpad -VERSION = '3.1.1' +VERSION = '3.1.2' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE @@ -1181,6 +1181,9 @@ _POLL_INTERVAL = 30 _POLL_TIMER = None _CARD_LOCK = threading.RLock() _CARD_CONNECTED = False +# True when the current PC/SC transport is unusable (service failure): the +# next equip recreates it via server.transport_factory (_ensure_transport). +_TRANSPORT_STALE = False def _set_poll_interval(seconds): global _POLL_INTERVAL @@ -1212,7 +1215,7 @@ def _do_status_poll(): _handle_proactive_chain(scc, st_sw) except Exception as e: sys.stderr.write('AUTO-STATUS error: %s\n' % e) - _handle_card_disconnect() + _handle_card_disconnect(stale=_is_pcsc_error(e)) _reset_poll_timer() def _poll_enable(): @@ -2512,8 +2515,24 @@ def _record_tr(entry, tr_tlv, tr_sw=None): entry['tr_decoded'] = [] -def _handle_card_disconnect(): - global _CARD_CONNECTED +def _is_pcsc_error(exc): + """True for a PC/SC-level failure. + + pyscard sets ``hresult`` on its exceptions (card-level errors such as + pySim's ``SwMatchError`` do not). After such a failure the PC/SC context + captured when the reader was opened is dead, so the transport has to be + recreated before the card can be used again.""" + return getattr(exc, 'hresult', -1) not in (-1, None) + + +def _handle_card_disconnect(stale=False): + """Tear down the card session; ``stale`` marks the transport as dead. + + Called on card removal (no ``stale``) and on PC/SC errors (``stale``, see + ``_is_pcsc_error``): the next equip recreates the transport.""" + global _CARD_CONNECTED, _TRANSPORT_STALE + if stale: + _TRANSPORT_STALE = True _poll_disable() _cancel_menu_timeout() _timer_cancel() @@ -2532,6 +2551,37 @@ def _handle_card_disconnect(): _reset_proactive_log() +def _ensure_transport(server): + """Recreate the PC/SC transport after a service failure. + + pyscard keeps the context handle it established when the reader was + opened (``PCSCCardConnection.connect`` uses it), so the old connection + cannot be revived once pcscd restarted - a fresh transport is the only + way back. ``server.transport_factory`` is installed by ``__main__``. + Returns False when the reconnect failed (the caller should give up).""" + global _TRANSPORT_STALE + if not _TRANSPORT_STALE or server is None: + return True + factory = getattr(server, 'transport_factory', None) + if factory is None: + _TRANSPORT_STALE = False + return True + try: + sl = factory() + except Exception as e: + sys.stderr.write('TRANSPORT: PC/SC reconnect failed: %s\n' % e) + return False + server.sl = sl + server.card = None + server.scc = None + app = getattr(server, 'app', None) + if app is not None: + app.sl = sl + _TRANSPORT_STALE = False + sys.stderr.write('TRANSPORT: PC/SC reconnected\n') + return True + + def _apply_equipped_card(server): """Common post-equip state refresh + TERMINAL PROFILE, shared by the /api/command equip branch and the auto-equip worker.""" @@ -2577,10 +2627,13 @@ def _esim_reinit(server): active profile), then the standard equip path runs. """ app = server.app + if not _ensure_transport(server): + return False server.equipping = True try: try: - server.scc.reset_card() + if server.scc: + server.scc.reset_card() except Exception as e: sys.stderr.write('ESIM: card reset failed: %s\n' % e) old_stdout, old_stderr = app.stdout, sys.stderr @@ -2688,6 +2741,10 @@ def _auto_equip_worker(): app = server.app if app is None or not getattr(server, 'terminal_profile', None): return + # A pcscd restart kills the PC/SC context: rebuild the transport + # before equipping, otherwise the equip can never succeed. + if not _ensure_transport(server): + return server.equipping = True try: sys.stderr.write('AUTO-EQUIP: card inserted, initializing\n') @@ -2745,16 +2802,77 @@ class _CardPresenceObserver(CardObserver): _card_presence_observer = None +_card_watchdog = None + + +def _card_monitor_alive(): + """True while pyscard's presence-monitoring thread is running. + + pyscard 2.x runs the thread eagerly (``_START_ON_DEMAND_ = False``) and + stops it itself on ``SCARD_E_NO_SERVICE``, so a server that outlives a + pcscd restart would never see card insertions again without a watchdog.""" + try: + from smartcard.CardMonitoring import CardMonitor + rmthread = getattr(CardMonitor(), 'rmthread', None) + thread = getattr(rmthread, 'instance', None) + return bool(thread is not None and thread.is_alive()) + except Exception: + return False + + +def _restart_card_monitor(reader_name): + """Recreate pyscard's presence-monitoring thread. + + The fresh thread reports cards that are already present as *inserted* on + its first pass, which triggers our auto-equip; the observers stay + registered on the untouched Observable singleton.""" + from smartcard.CardMonitoring import CardMonitor, CardMonitoringThread + monitor = CardMonitor().instance + CardMonitoringThread.instance = None + monitor.rmthread = CardMonitoringThread(monitor) + + +def _watchdog_tick(reader_name, alive_fn=None, restart_fn=None): + """One presence-monitor watchdog pass; returns 'ok' or 'restarted'.""" + alive_fn = alive_fn or _card_monitor_alive + restart_fn = restart_fn or _restart_card_monitor + if alive_fn(): + return 'ok' + sys.stderr.write('CARD-WATCH: PC/SC presence monitor stopped; restarting\n') + restart_fn(reader_name) + return 'restarted' + + +def _start_card_watchdog(reader_name, interval=5.0): + """Keep the pyscard presence monitor alive across pcscd restarts.""" + if not reader_name: + return None + + def run(): + while True: + time.sleep(interval) + try: + _watchdog_tick(reader_name) + except Exception as e: + sys.stderr.write('CARD-WATCH: monitor restart failed: %s\n' % e) + + thread = threading.Thread(target=run, name='card-watchdog', daemon=True) + thread.start() + return thread + def start_card_monitor(reader_name): """Start the process-wide pyscard monitor (one daemon thread, no process) - and register our reader's presence observer.""" - global _card_presence_observer + and register our reader's presence observer; a watchdog keeps the monitor + (and with it auto-equip) alive when pcscd restarts.""" + global _card_presence_observer, _card_watchdog if not reader_name: return None if _card_presence_observer is None: _card_presence_observer = _CardPresenceObserver(reader_name) CardMonitor().addObserver(_card_presence_observer) + if _card_watchdog is None or not _card_watchdog.is_alive(): + _card_watchdog = _start_card_watchdog(reader_name) return _card_presence_observer @@ -2976,7 +3094,7 @@ def _timer_expired(timer_id, elapsed): data, sw = scc._tp.send_apdu(apdu) except Exception as e: sys.stderr.write('TIMER-EXPIRATION send error: %s\n' % e) - _handle_card_disconnect() + _handle_card_disconnect(stale=_is_pcsc_error(e)) return sys.stderr.write('ENVELOPE(Timer Expiration): timer=%d elapsed=%ds -> %s\n' % (timer_id, elapsed, sw)) @@ -3782,6 +3900,11 @@ class PysimHandler(BaseHTTPRequestHandler): body = self._read_body() self._log_req(body) cmd = body.get('cmd', '') + is_equip = str(cmd).strip().startswith('equip') + if is_equip: + # A pcscd restart kills the PC/SC context; rebuild the + # transport so a manual Equip recovers too. + _ensure_transport(self.server) t0 = time.time() out = StringIO() old_stdout = app.stdout @@ -3798,7 +3921,6 @@ class PysimHandler(BaseHTTPRequestHandler): sys.stderr = old_stderr elapsed = int((time.time() - t0) * 1000) status = 'OK' if not output or 'not a recognized command' not in output else 'ERROR' - is_equip = str(cmd).strip().startswith('equip') if is_equip: _tlog('equip: onecmd_plus_hooks %dms' % elapsed) if is_equip and self.server.app and self.server.app.card and self.server.terminal_profile: @@ -3928,7 +4050,7 @@ class PysimHandler(BaseHTTPRequestHandler): self._log_resp(resp) except Exception as e: sys.stderr.write('STATUS poll error: %s\n' % e) - _handle_card_disconnect() + _handle_card_disconnect(stale=_is_pcsc_error(e)) self._send_json({'sw': None, 'error': 'card disconnected'}) self._log_resp({'sw': None, 'error': 'card disconnected'}) elif self.path == '/api/net-sim': @@ -3960,7 +4082,7 @@ class PysimHandler(BaseHTTPRequestHandler): self._log_resp({'error': str(e)}) except Exception as e: sys.stderr.write('Net-sim error: %s\n' % e) - _handle_card_disconnect() + _handle_card_disconnect(stale=_is_pcsc_error(e)) self._send_json({'error': 'simulation failed: %s' % e}, 500) self._log_resp({'error': str(e)}) elif self.path == '/api/net-state-refresh': @@ -4314,8 +4436,8 @@ class PysimHandler(BaseHTTPRequestHandler): self._log_resp(resp) except Exception as e: sys.stderr.write('Handler error: %s\n' % e) - if 'Card' in str(e) or 'Transaction' in str(e) or 'Transmit' in str(e): - _handle_card_disconnect() + if _is_pcsc_error(e) or 'Card' in str(e) or 'Transaction' in str(e) or 'Transmit' in str(e): + _handle_card_disconnect(stale=_is_pcsc_error(e)) err = {'success': False, 'error': str(e), 'exists': False} self._send_json(err, 500) self._log_resp(err) @@ -4394,7 +4516,7 @@ class PysimHandler(BaseHTTPRequestHandler): self._log_resp(resp) except Exception as e: sys.stderr.write('Event send error: %s\n' % e) - _handle_card_disconnect() + _handle_card_disconnect(stale=_is_pcsc_error(e)) self._send_json({'sw': None, 'error': 'card disconnected'}) self._log_resp({'sw': None, 'error': 'card disconnected'}) elif self.path == '/api/pli-dict': @@ -4531,8 +4653,8 @@ class PysimHandler(BaseHTTPRequestHandler): self._log_resp(resp) except Exception as e: sys.stderr.write('OTA send error: %s\n' % e) - if 'Card' in str(e) or 'Transaction' in str(e) or 'Transmit' in str(e): - _handle_card_disconnect() + if _is_pcsc_error(e) or 'Card' in str(e) or 'Transaction' in str(e) or 'Transmit' in str(e): + _handle_card_disconnect(stale=_is_pcsc_error(e)) err = {'success': False, 'error': str(e)} self._send_json(err, 500) self._log_resp(err) diff --git a/tests/test_card_recovery.py b/tests/test_card_recovery.py new file mode 100644 index 0000000..6ca8439 --- /dev/null +++ b/tests/test_card_recovery.py @@ -0,0 +1,105 @@ +# coding=utf-8 +"""Tests for the PC/SC failure recovery: presence-monitor watchdog and +transport recreation after a service failure (pcscd restart).""" + +import unittest +from types import SimpleNamespace + +from pysim_simple_server import server + + +class PcscErrorTests(unittest.TestCase): + def test_pyscard_exception_counts_as_pcsc_error(self): + class FakePcscError(Exception): + def __init__(self): + self.hresult = 0x8010001D # SCARD_E_NO_SERVICE + + self.assertTrue(server._is_pcsc_error(FakePcscError())) + + def test_card_level_errors_do_not(self): + self.assertFalse(server._is_pcsc_error(RuntimeError('SW match failed'))) + self.assertFalse(server._is_pcsc_error(Exception())) + + +class WatchdogTests(unittest.TestCase): + def test_alive_monitor_is_left_alone(self): + calls = [] + rv = server._watchdog_tick('reader', alive_fn=lambda: True, + restart_fn=lambda name: calls.append(name)) + self.assertEqual(rv, 'ok') + self.assertEqual(calls, []) + + def test_stopped_monitor_is_restarted(self): + calls = [] + rv = server._watchdog_tick('reader', alive_fn=lambda: False, + restart_fn=lambda name: calls.append(name)) + self.assertEqual(rv, 'restarted') + self.assertEqual(calls, ['reader']) + + +class EnsureTransportTests(unittest.TestCase): + def setUp(self): + server._TRANSPORT_STALE = False + + def tearDown(self): + server._TRANSPORT_STALE = False + + @staticmethod + def make_server(factory): + app = SimpleNamespace(sl='old-sl') + return SimpleNamespace(sl='old-sl', scc='old-scc', card='old-card', + app=app, transport_factory=factory) + + def test_fresh_transport_is_kept(self): + calls = [] + srv = self.make_server(lambda: calls.append(1) or 'new-sl') + self.assertTrue(server._ensure_transport(srv)) + self.assertEqual(calls, []) + self.assertEqual(srv.sl, 'old-sl') + + def test_stale_transport_is_recreated(self): + srv = self.make_server(lambda: 'new-sl') + server._TRANSPORT_STALE = True + self.assertTrue(server._ensure_transport(srv)) + self.assertEqual(srv.sl, 'new-sl') + self.assertEqual(srv.app.sl, 'new-sl') + self.assertIsNone(srv.scc) + self.assertIsNone(srv.card) + self.assertFalse(server._TRANSPORT_STALE) + + def test_failed_reconnect_keeps_the_transport_stale(self): + def boom(): + raise RuntimeError('service not available') + + srv = self.make_server(boom) + server._TRANSPORT_STALE = True + self.assertFalse(server._ensure_transport(srv)) + self.assertTrue(server._TRANSPORT_STALE) + self.assertEqual(srv.sl, 'old-sl') + + def test_no_factory_clears_the_flag(self): + srv = SimpleNamespace(sl='old-sl', app=None, transport_factory=None) + server._TRANSPORT_STALE = True + self.assertTrue(server._ensure_transport(srv)) + self.assertFalse(server._TRANSPORT_STALE) + + def test_none_server_is_a_noop(self): + server._TRANSPORT_STALE = True + self.assertTrue(server._ensure_transport(None)) + self.assertTrue(server._TRANSPORT_STALE) + + +class DisconnectTests(unittest.TestCase): + def setUp(self): + server._TRANSPORT_STALE = False + + def tearDown(self): + server._TRANSPORT_STALE = False + + def test_stale_disconnect_marks_the_transport(self): + server._handle_card_disconnect(stale=True) + self.assertTrue(server._TRANSPORT_STALE) + + def test_plain_disconnect_leaves_the_transport_usable(self): + server._handle_card_disconnect() + self.assertFalse(server._TRANSPORT_STALE)