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.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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 =====
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'simple-v236';
|
||||
const CACHE = 'simple-v237';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+138
-16
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user