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:
2026-09-22 22:01:28 +03:00
parent 7fd3a3c973
commit b87b3905ee
8 changed files with 253 additions and 19 deletions
+1
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v236';
const CACHE = 'simple-v237';
const URLS = [
'index.html',
'help.html',
+1 -1
View File
@@ -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.
+5
View File
@@ -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
View File
@@ -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)
+105
View File
@@ -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)