2 Commits

Author SHA1 Message Date
catarrh f92eb8f131 v3.32.5: a card removed during the startup init leaves it cardless
release / release (push) Waiting to run
Live log: a card was pulled while the server started and the TERMINAL
PROFILE send raised CardConnectionException 0x80100016
(SCARD_E_NOT_TRANSACTED).  The init's except caught it (no crash), but
the code then continued with the card object it had already built:
card_present and _CARD_CONNECTED became True - a phantom equipped card -
and the auto-equip's _CARD_CONNECTED early return then skipped every
later insertion, leaving only a manual Equip or a restart.

A PC/SC error in the startup init now prints one line (a traceback is
noise for a normal removal), clears the locals that feed those flags and
calls _clear_app_card_state(app); the presence monitor equips the next
card.  Non-PC/SC errors keep their traceback.

Pinned in tests/test_startup_cardless.py (the branch and the flag
sources).  1016 Python + 953 frontend green; simple-v463.
2026-10-21 14:52:12 +03:00
catarrh 7e8c483eb1 v3.32.4: rebuild the link on card insertion (the first attempt)
release / release (push) Waiting to run
v3.32.2 fixed the endless re-arm, but on Windows the first attempt
still failed on the old link (seen in the confirmation log: the
"self._con.connect()" traceback) and only the retry recovered - a failed
init, a 2-4 s delay and a traceback in the log.

The monitor's insert path now marks the transport stale under
_CARD_LOCK, so the first equip attempt rebuilds the PC/SC link.  It also
covers a link created at a cardless startup (never connected).  The
rebuild still happens in the equip worker, never in the pyscard monitor
thread, and _ensure_transport releases the old link first.

Docs: the READMEs' troubleshooting bullet.
Tests: an insertion marks the transport for the rebuild.
1014 Python + 953 frontend green; simple-v462.
2026-10-21 14:44:34 +03:00
11 changed files with 142 additions and 11 deletions
+35
View File
@@ -9,6 +9,41 @@ every change carries a descriptive commit message.
## [Unreleased] ## [Unreleased]
## [3.32.5] — 2026-10-21
### Fixed
- A card removed during the **startup initialization** no longer leaves the server
reporting a phantom equipped card. The init caught the PC/SC error (and printed a
traceback) but kept the card object it had already built, so `card_present` and
`_CARD_CONNECTED` stayed True — and the auto-equip then skipped every later insertion
(its `_CARD_CONNECTED` early return), leaving only a manual Equip or a restart. A
PC/SC error now prints one line (`INIT: card removed during initialization …`), drops
the half-initialized session and lets the presence monitor equip the next card.
## [3.32.4] — 2026-10-21
### Fixed
- A card insertion now marks the PC/SC link for a rebuild, so the **first** auto-equip
attempt already uses a fresh link. On Windows the old link cannot connect after a
swap — v3.32.2 recovered it, but only on the second attempt, after a failed init, a
2–4 s delay and a traceback in the log. A link created at a cardless startup has
never connected either, which the same mark covers. The rebuild still runs in the
equip worker (never in the pyscard monitor thread) and `_ensure_transport` releases
the old link first.
## [3.32.3] — 2026-10-21
### Fixed
- The auto-equip's captured traceback now always includes the **exception line**: cmd2
renders a rich-box traceback longer than the logged head, so the actual error was cut
off (seen in the first Windows confirmation of v3.32.2). The tail is appended after a
`...` marker.
- The "releasing the old link failed" message now says it continues with a fresh link —
a removed card's dead PC/SC handle always fails to disconnect.
## [3.32.2] — 2026-10-21 ## [3.32.2] — 2026-10-21
### Fixed ### Fixed
+1 -1
View File
@@ -799,7 +799,7 @@ its MSVC build chain) is not pulled in.
- **"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`. - **"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. - **"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 (releasing the old link first). No server restart needed. - **"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 (releasing the old link first). No server restart needed.
- **"Card was removed" errors / auto-equip fails after a card swap** — the auto-equip retries up to 3 times, and **every failed attempt rebuilds the PC/SC transport** before the next one: after a card swap on Windows the old link can stay unable to connect, which used to fail every retry and re-arm forever. The swallowed init error (with its traceback) is now printed as `AUTO-EQUIP: init output:`, and a card that is present but produced no card is a failure, not a silent "card gone". The watchdog re-arms with a doubling delay (5 s up to 60 s) while a card is present; a removed card clears the card state completely, so handlers answer "no card" instead of transmitting over the dead card. - **"Card was removed" errors / auto-equip fails after a card swap** — a card insertion marks the PC/SC link for a rebuild (so the **first** attempt already uses a fresh link — on Windows the old one cannot connect after a swap), the auto-equip retries up to 3 times, and **every failed attempt rebuilds the transport** before the next one. A card removed while the server initializes at startup leaves it cardless with one line (no traceback), and the next insertion equips normally. The swallowed init error (with its traceback) is now printed as `AUTO-EQUIP: init output:`, and a card that is present but produced no card is a failure, not a silent "card gone". The watchdog re-arms with a doubling delay (5 s up to 60 s) while a card is present; a removed card clears the card state completely, so handlers answer "no card" instead of transmitting over the dead card.
### API reference ### API reference
+1 -1
View File
@@ -783,7 +783,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`. - **"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`. Сервер всё равно стартует без ридера. - **"device file /dev/ttyUSB0 does not exist"** — нет serial-ридера; подключите USB-ридер или укажите `-d`. Сервер всё равно стартует без ридера.
- **"Service not available" (0x8010001D) / ридер пропал после перезапуска `pcscd`** — сервер восстанавливается сам: сторож перезапускает монитор присутствия pyscard, а следующий equip (автоматически после повторной вставки карты или кнопкой **Equip**) пересоздаёт транспорт PC/SC (сначала освобождая старый линк). Перезапуск сервера не нужен. - **"Service not available" (0x8010001D) / ридер пропал после перезапуска `pcscd`** — сервер восстанавливается сам: сторож перезапускает монитор присутствия pyscard, а следующий equip (автоматически после повторной вставки карты или кнопкой **Equip**) пересоздаёт транспорт PC/SC (сначала освобождая старый линк). Перезапуск сервера не нужен.
- **Ошибки "Card was removed" / equip не срабатывает после смены карты** — авто-equip повторяется до 3 раз, и **каждая неудачная попытка пересоздаёт транспорт PC/SC** перед следующей: после смены карты в Windows старое соединение может остаться неспособным подключиться, из-за чего раньше все повторы падали, а сторож перезапускал попытки бесконечно. Проглоченная ошибка инициализации (с трассировкой) теперь печатается как `AUTO-EQUIP: init output:`, а «карта есть, но карта не создана» — это ошибка, а не тихое «карта пропала». Сторож повторяет с удвоением задержки (5 с … 60 с), пока карта в ридере; снятие карты полностью очищает состояние, поэтому обработчики отвечают «нет карты», а не шлют APDU мёртвой карте. - **Ошибки "Card was removed" / equip не срабатывает после смены карты** — вставка карты помечает соединение PC/SC для пересоздания (поэтому **первая** попытка уже использует свежее соединение — в Windows старое после смены карты подключиться не может), авто-equip повторяется до 3 раз, и **каждая неудачная попытка пересоздаёт транспорт** перед следующей. Карта, снятая во время инициализации при старте сервера, оставляет его без карты с одной строкой (без трассировки), а следующая вставка инициализируется обычным образом. Проглоченная ошибка инициализации (с трассировкой) теперь печатается как `AUTO-EQUIP: init output:`, а «карта есть, но карта не создана» — это ошибка, а не тихое «карта пропала». Сторож повторяет с удвоением задержки (5 с … 60 с), пока карта в ридере; снятие карты полностью очищает состояние, поэтому обработчики отвечают «нет карты», а не шлют APDU мёртвой карте.
### Справочник API ### Справочник API
+1 -1
View File
@@ -1943,7 +1943,7 @@
// ===== Version ===== // ===== Version =====
// Single source of truth for the PWA version: shown in the header and used // Single source of truth for the PWA version: shown in the header and used
// by the server version check in pysimConnect(). // by the server version check in pysimConnect().
const SIMPLE_VERSION = '3.32.2'; const SIMPLE_VERSION = '3.32.5';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== view history (the browser Back/Forward buttons) ===== // ===== view history (the browser Back/Forward buttons) =====
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v460'; const CACHE = 'simple-v463';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "pysim-simple-server" name = "pysim-simple-server"
version = "3.32.2" version = "3.32.5"
description = "HTTP REST server wrapping pysim for the SIMple PWA" description = "HTTP REST server wrapping pysim for the SIMple PWA"
license = { text = "GPL-2.0-or-later" } license = { text = "GPL-2.0-or-later" }
requires-python = ">=3.9" requires-python = ">=3.9"
+21 -3
View File
@@ -18,7 +18,7 @@ from . import presets
from . import test_scripts from . import test_scripts
from . import test_suites from . import test_suites
from . import testscript from . import testscript
from .server import PysimHandler, StderrApduTracer, _LoggingApduTracer, VERSION, _send_terminal_profile, _DefaultProactiveHandler, _handle_proactive_chain, _send_status, _init_proactive_session, _timing_on, _tlog, _set_menu_timeout, start_card_monitor, set_auto_equip, _read_iccid, _read_arr, _read_pbr, _netstate_read, _netstate_install, _LineFilter, _test_command_type, migrate_scripts_to_suites from .server import PysimHandler, StderrApduTracer, _LoggingApduTracer, VERSION, _send_terminal_profile, _DefaultProactiveHandler, _handle_proactive_chain, _send_status, _init_proactive_session, _timing_on, _tlog, _set_menu_timeout, start_card_monitor, set_auto_equip, _read_iccid, _read_arr, _read_pbr, _netstate_read, _netstate_install, _LineFilter, _test_command_type, migrate_scripts_to_suites, _is_pcsc_error, _clear_app_card_state
_server_start = 0 _server_start = 0
@@ -295,8 +295,26 @@ def main():
break break
_handle_proactive_chain(scc, st_sw) _handle_proactive_chain(scc, st_sw)
_tlog('terminal_profile_drain: %.0fms' % ((time.time() - t_phase) * 1000)) _tlog('terminal_profile_drain: %.0fms' % ((time.time() - t_phase) * 1000))
except Exception: except Exception as e:
traceback.print_exc(file=sys.stderr) if _is_pcsc_error(e):
# The card (or the reader) went away during the startup init -
# a normal state, not a crash. Drop the half-initialized
# session: keeping the card object would leave the server
# reporting a phantom equipped card, and the auto-equip would
# then skip every later insertion (_CARD_CONNECTED is True).
# The presence monitor equips the next card (live 2026-10-21).
sys.stderr.write('INIT: card removed during initialization - server ready; '
'insert a card or press Equip\n')
iccid = None
netstate_files = None
arr_files = None
pbr_files = None
card = None
rs = None
scc = None
_clear_app_card_state(app)
else:
traceback.print_exc(file=sys.stderr)
if app is not None and tracer is not None: if app is not None and tracer is not None:
if opts.apdu_trace: if opts.apdu_trace:
# PysimApp.__init__ routes PySimLogger through app.poutput() (app.stdout) # PysimApp.__init__ routes PySimLogger through app.poutput() (app.stdout)
+20 -3
View File
@@ -37,7 +37,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad from osmocom.utils import rpad
VERSION = '3.32.2' VERSION = '3.32.5'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -5426,7 +5426,8 @@ def _ensure_transport(server):
try: try:
old.disconnect() old.disconnect()
except Exception as e: except Exception as e:
sys.stderr.write('TRANSPORT: releasing the old link failed: %s\n' % e) sys.stderr.write('TRANSPORT: releasing the old link failed '
'(continuing with a fresh one): %s\n' % e)
try: try:
sl = factory() sl = factory()
except Exception as e: except Exception as e:
@@ -5744,8 +5745,15 @@ def _auto_equip_attempt(server):
lines = captured.splitlines() lines = captured.splitlines()
start = next(i for i, l in enumerate(lines) start = next(i for i, l in enumerate(lines)
if 'Traceback (most recent call last)' in l) if 'Traceback (most recent call last)' in l)
body = lines[start:start + 10]
tail = [l for l in lines[start:] if l.strip()][-3:]
if tail and tail[-1] not in body:
# The exception itself is the last line of the traceback;
# a rich-box traceback can be longer than the head slice.
body.append('...')
body.extend(tail)
sys.stderr.write('AUTO-EQUIP: init output:\n%s\n' sys.stderr.write('AUTO-EQUIP: init output:\n%s\n'
% '\n'.join(lines[start:start + 12])) % '\n'.join(body))
# ANY failed attempt leaves the PC/SC card handle suspect: after a # ANY failed attempt leaves the PC/SC card handle suspect: after a
# card swap on Windows the link can stay unable to connect, so # card swap on Windows the link can stay unable to connect, so
# every retry failed and the watchdog re-armed forever (live # every retry failed and the watchdog re-armed forever (live
@@ -5819,6 +5827,15 @@ class _CardPresenceObserver(CardObserver):
with _CARD_LOCK: with _CARD_LOCK:
if _server_ref: if _server_ref:
_server_ref.card_present = True _server_ref.card_present = True
# A freshly inserted card gets a fresh PC/SC link: after a
# swap the old link can stay unable to connect (seen on
# Windows: the first init failed and only the retry's
# rebuild recovered it), and a link created at a cardless
# startup has never connected. Marking it here (under the
# lock) makes the FIRST attempt rebuild - the rebuild
# itself runs in the equip worker, never in this thread.
global _TRANSPORT_STALE
_TRANSPORT_STALE = True
trigger_auto = True trigger_auto = True
if trigger_auto and _AUTO_EQUIP: if trigger_auto and _AUTO_EQUIP:
_auto_equip_trigger() _auto_equip_trigger()
+13
View File
@@ -35,8 +35,11 @@ class TestCardPresenceObserver(unittest.TestCase):
self.trigger_mock = self.trigger.start() self.trigger_mock = self.trigger.start()
self.saved_auto = S._AUTO_EQUIP self.saved_auto = S._AUTO_EQUIP
S._AUTO_EQUIP = True S._AUTO_EQUIP = True
self.saved_stale = S._TRANSPORT_STALE
S._TRANSPORT_STALE = False
def tearDown(self): def tearDown(self):
S._TRANSPORT_STALE = self.saved_stale
S._AUTO_EQUIP = self.saved_auto S._AUTO_EQUIP = self.saved_auto
self.trigger.stop() self.trigger.stop()
self.patcher.stop() self.patcher.stop()
@@ -61,6 +64,16 @@ class TestCardPresenceObserver(unittest.TestCase):
self.assertEqual(self.disconnects, []) self.assertEqual(self.disconnects, [])
self.trigger_mock.assert_called_once() self.trigger_mock.assert_called_once()
def test_insertion_marks_the_transport_for_a_rebuild(self):
# The first equip attempt must already use a fresh link: after a swap
# the old one cannot connect on Windows, and a cardless-startup link
# never connected (v3.32.4).
S._TRANSPORT_STALE = False
self.server.card_present = False
self.observer.update(None, ([FakeCard('Test Reader 00 00')], []))
self.assertTrue(S._TRANSPORT_STALE)
self.trigger_mock.assert_called_once()
def test_insertion_does_not_trigger_when_disabled(self): def test_insertion_does_not_trigger_when_disabled(self):
S._AUTO_EQUIP = False S._AUTO_EQUIP = False
self.server.card_present = False self.server.card_present = False
+22
View File
@@ -444,6 +444,28 @@ class AutoEquipTests(unittest.TestCase):
self.assertIn('AUTO-EQUIP: init output:', out) self.assertIn('AUTO-EQUIP: init output:', out)
self.assertIn('ProtocolError: Connection failed', out) self.assertIn('ProtocolError: Connection failed', out)
def test_a_long_traceback_keeps_its_exception_line(self):
# cmd2 renders a rich-box traceback; a fixed head slice cut the
# exception itself off (seen live on Windows 2026-10-21), so the tail
# is always included.
srv = self.make_server(None)
def onecmd(cmd):
srv.app.card = SimpleNamespace(_scc='scc')
sys.stderr.write('Traceback (most recent call last):\n')
for i in range(30):
sys.stderr.write(' File "f%d", line %d, in step%d\n' % (i, i, i))
sys.stderr.write('CardConnectionException: No card is inserted\n')
srv.app.onecmd_plus_hooks = onecmd
with mock.patch.object(server, '_ensure_transport', lambda s: True), \
mock.patch.object(server, '_apply_equipped_card', lambda s: None), \
mock.patch('sys.stderr', new=StringIO()) as err:
self.assertFalse(server._auto_equip_attempt(srv))
out = err.getvalue()
self.assertIn('AUTO-EQUIP: init output:', out)
self.assertIn('CardConnectionException: No card is inserted', out)
def test_failed_attempt_unequips_the_half_initialized_shell(self): def test_failed_attempt_unequips_the_half_initialized_shell(self):
calls = [] calls = []
srv = self.make_server(None) srv = self.make_server(None)
+26
View File
@@ -8,6 +8,7 @@ line and no traceback, and the pySim-internal 'Waiting for card...' /
failure keeps the stock-pysim fallback and its traceback. failure keeps the stock-pysim fallback and its traceback.
""" """
import inspect
import io import io
import sys import sys
import unittest import unittest
@@ -158,3 +159,28 @@ def _parser():
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()
class StartupCardLostTests(unittest.TestCase):
"""A card removed during the startup init leaves the server cardless.
The startup code used to keep the card object it had already built:
``card_present`` and ``_CARD_CONNECTED`` stayed True, so the server
reported a phantom equipped card and the auto-equip skipped every later
insertion (its ``_CARD_CONNECTED`` early return). The PC/SC error must
drop the half-initialized session instead - and not print a traceback for
a normal removal."""
def test_a_pcsc_interruption_drops_the_session(self):
src = inspect.getsource(srv_main.main)
self.assertIn('_is_pcsc_error(e)', src)
self.assertIn('card removed during initialization', src)
self.assertIn('_clear_app_card_state(app)', src)
# the locals that feed card_present / _CARD_CONNECTED are cleared
for line in ('iccid = None', 'card = None', 'rs = None', 'scc = None'):
self.assertIn(line, src)
def test_the_session_state_is_derived_from_the_cleared_card(self):
# the assignments that would otherwise report a phantom card
src = inspect.getsource(srv_main.main)
self.assertIn('server.card_present = card is not None', src)
self.assertIn('_CARD_CONNECTED = card is not None', src)