2 Commits

Author SHA1 Message Date
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
catarrh 8a08f642d4 v3.32.2: the Windows card-swap auto-equip loop
release / release (push) Waiting to run
After a card swap (or the first insertion after startup) on Windows the
auto-equip looped forever: "card inserted, initializing" / "card gone
during initialization", with no "retry 2/3" lines.

An equip with the card still present produced no card (app.card None),
and that branch reported "card gone" and returned True - a no-op success
- so the three retries never ran and the watchdog re-armed every ~5 s.

- The card-gone no-op is now only for a real removal (card_present
  False); a card-present init that produced no card raises a failure.
- Any failed attempt marks the transport stale, so the next attempt
  rebuilds the PC/SC link (a Windows swap can leave the old link unable
  to connect; _ensure_transport releases the old link before building
  the new one, so the 2026-09-24 stale-handle bug cannot recur).
- The error pySim swallowed (cmd2) is printed as "AUTO-EQUIP: init
  output:" with its traceback - the failure is diagnosable from the log.
- Docs: the READMEs' troubleshooting bullet.
- Version strings + the PWA cache bumped together (simple-v460).

Tests: a card-present init without a card is a failure, a mid-init
removal stays a silent no-op, any failure marks the transport, the
stale-handle traceback reaches the log, and an end-to-end retry: a
failed init, the old link released, a fresh one built, the retry
succeeds.  1012 Python + 953 frontend green.
2026-10-21 14:35:44 +03:00
9 changed files with 211 additions and 18 deletions
+35
View File
@@ -9,6 +9,41 @@ every change carries a descriptive commit message.
## [Unreleased]
## [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
### Fixed
- **The auto-equip loop after a card swap on Windows.** A failed initialization with
the card still present was reported as "card gone during initialization" and treated
as a no-op success — so the three retries never ran and the watchdog re-armed every
few seconds forever. It is a failure now, and **every failed attempt marks the
PC/SC transport for a rebuild**, so the retry starts from a fresh link (the release
of the old link happens first, so it cannot inherit a stale card handle). The error
pySim swallowed is printed as `AUTO-EQUIP: init output:` with its traceback.
## [3.32.1] — 2026-10-21
### Added
+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`.
- **"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.
- **"Card was removed" errors / auto-equip fails after a card swap** — a normal card swap reconnects on the existing transport (card-level PC/SC errors no longer force a transport rebuild), a failed auto-equip is retried up to 3 times, and the watchdog re-arms it every 5 s while a card is present. A removed card also 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. 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
+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`.
- **"device file /dev/ttyUSB0 does not exist"** — нет serial-ридера; подключите USB-ридер или укажите `-d`. Сервер всё равно стартует без ридера.
- **"Service not available" (0x8010001D) / ридер пропал после перезапуска `pcscd`** — сервер восстанавливается сам: сторож перезапускает монитор присутствия pyscard, а следующий equip (автоматически после повторной вставки карты или кнопкой **Equip**) пересоздаёт транспорт PC/SC (сначала освобождая старый линк). Перезапуск сервера не нужен.
- **Ошибки "Card was removed" / equip не срабатывает после смены карты** — обычная смена карты переподключается на существующем транспорте (карточные ошибки PC/SC больше не вызывают пересоздание транспорта), неудавшийся авто-equip повторяется до 3 раз, а сторож перезапускает его каждые 5 с, пока карта в ридере. Снятие карты теперь полностью очищает состояние, поэтому обработчики отвечают «нет карты», а не шлют APDU мёртвой карте.
- **Ошибки "Card was removed" / equip не срабатывает после смены карты** — вставка карты помечает соединение PC/SC для пересоздания (поэтому **первая** попытка уже использует свежее соединение — в Windows старое после смены карты подключиться не может), авто-equip повторяется до 3 раз, и **каждая неудачная попытка пересоздаёт транспорт** перед следующей. Проглоченная ошибка инициализации (с трассировкой) теперь печатается как `AUTO-EQUIP: init output:`, а «карта есть, но карта не создана» — это ошибка, а не тихое «карта пропала». Сторож повторяет с удвоением задержки (5 с … 60 с), пока карта в ридере; снятие карты полностью очищает состояние, поэтому обработчики отвечают «нет карты», а не шлют APDU мёртвой карте.
### Справочник API
+1 -1
View File
@@ -1943,7 +1943,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.32.1';
const SIMPLE_VERSION = '3.32.4';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== view history (the browser Back/Forward buttons) =====
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v459';
const CACHE = 'simple-v462';
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.32.1"
version = "3.32.4"
description = "HTTP REST server wrapping pysim for the SIMple PWA"
license = { text = "GPL-2.0-or-later" }
requires-python = ">=3.9"
+47 -10
View File
@@ -37,7 +37,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad
VERSION = '3.32.1'
VERSION = '3.32.4'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -5426,7 +5426,8 @@ def _ensure_transport(server):
try:
old.disconnect()
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:
sl = factory()
except Exception as e:
@@ -5701,8 +5702,8 @@ def _auto_equip_attempt(server):
if not _ensure_transport(server):
return False
server.equipping = True
out = StringIO()
try:
out = StringIO()
old_stdout, old_stderr = app.stdout, sys.stderr
old_debug = getattr(app, 'debug', None)
app.stdout = out
@@ -5716,9 +5717,17 @@ def _auto_equip_attempt(server):
app.debug = old_debug
app.stdout = old_stdout
sys.stderr = old_stderr
if not getattr(server, 'card_present', False) or server.app.card is None:
if not getattr(server, 'card_present', False):
# A real removal: _handle_card_disconnect already cleared the
# session, nothing to retry.
sys.stderr.write('AUTO-EQUIP: card gone during initialization\n')
return True
if server.app.card is None:
# The presence monitor still reports the card, so this is a
# failed initialization, not a swap - never a silent no-op
# (a Windows card swap showed an endless "card gone"
# re-arm loop because this returned True: 2026-10-21).
raise RuntimeError('the card is present but the init produced no card')
if 'Traceback (most recent call last)' in out.getvalue():
raise RuntimeError('equip reported an error (see the captured output)')
if not _app_equip_complete(app):
@@ -5729,12 +5738,31 @@ def _auto_equip_attempt(server):
return True
except Exception as e:
sys.stderr.write('AUTO-EQUIP failed: %s\n' % e)
# A dead transport must be rebuilt before the next attempt;
# card-level failures reconnect on the existing link. Unequip the
# half-initialized shell as well: a failed init can leave command
# sets registered that the next equip would refuse to re-register.
if _is_transport_fatal(e):
_handle_card_disconnect(stale=True)
# Show what pySim swallowed (cmd2 printed it into the capture):
# without it the failure is undiagnosable in the run log.
captured = out.getvalue().strip()
if 'Traceback (most recent call last)' in captured:
lines = captured.splitlines()
start = next(i for i, l in enumerate(lines)
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'
% '\n'.join(body))
# ANY failed attempt leaves the PC/SC card handle suspect: after a
# card swap on Windows the link can stay unable to connect, so
# every retry failed and the watchdog re-armed forever (live
# 2026-10-21). Mark the transport stale so the retry rebuilds it -
# _ensure_transport releases the old link first, so the new one
# never inherits a stale handle (the 2026-09-24 bug).
_handle_card_disconnect(stale=True)
# Unequip the half-initialized shell too: a failed init can leave
# command sets registered that the next equip would refuse.
_clear_app_card_state(app)
return False
finally:
@@ -5799,6 +5827,15 @@ class _CardPresenceObserver(CardObserver):
with _CARD_LOCK:
if _server_ref:
_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
if trigger_auto and _AUTO_EQUIP:
_auto_equip_trigger()
+13
View File
@@ -35,8 +35,11 @@ class TestCardPresenceObserver(unittest.TestCase):
self.trigger_mock = self.trigger.start()
self.saved_auto = S._AUTO_EQUIP
S._AUTO_EQUIP = True
self.saved_stale = S._TRANSPORT_STALE
S._TRANSPORT_STALE = False
def tearDown(self):
S._TRANSPORT_STALE = self.saved_stale
S._AUTO_EQUIP = self.saved_auto
self.trigger.stop()
self.patcher.stop()
@@ -61,6 +64,16 @@ class TestCardPresenceObserver(unittest.TestCase):
self.assertEqual(self.disconnects, [])
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):
S._AUTO_EQUIP = False
self.server.card_present = False
+111 -3
View File
@@ -269,9 +269,50 @@ class AutoEquipTests(unittest.TestCase):
self.assertTrue(server._auto_equip_attempts(srv, 3, lambda s: None))
self.assertEqual(state['calls'], 2)
self.assertEqual(applied, [srv])
# the failed attempt left the handle suspect; the retry's
# _ensure_transport (patched here) would rebuild and clear the flag
self.assertTrue(server._TRANSPORT_STALE)
def test_a_failed_init_rebuilds_the_transport_before_the_retry(self):
# The Windows swap end to end: the first init fails with the card
# present, the retry starts from a fresh PC/SC link and succeeds -
# and the old link is released before the new one is built.
srv = self.make_server(None)
released = []
built = []
class Link:
def disconnect(self):
released.append(self)
old = Link()
srv.sl = old
srv.transport_factory = lambda: built.append(Link()) or built[-1]
calls = {'n': 0}
def onecmd(cmd):
calls['n'] += 1
if calls['n'] == 1:
raise RuntimeError('ProtocolError: Connection failed')
# a completed equip installs both (the failure unequipped the shell)
srv.app.card = SimpleNamespace(_scc='scc')
srv.app.lchan = 'lchan'
srv.app.onecmd_plus_hooks = onecmd
with mock.patch.object(server, '_apply_equipped_card', lambda s: None):
self.assertTrue(server._auto_equip_attempts(srv, 3, lambda s: None))
self.assertEqual(calls['n'], 2)
self.assertEqual(len(built), 1)
self.assertEqual(released, [old])
self.assertIs(srv.sl, built[0])
self.assertFalse(server._TRANSPORT_STALE)
def test_card_level_failure_keeps_the_transport(self):
def test_a_failed_attempt_marks_the_transport_for_the_rebuild(self):
# A card-level error used to keep the link: after a Windows card swap
# the link stayed unable to connect, every retry failed and the
# watchdog re-armed forever (live 2026-10-21). Any failed attempt now
# marks the transport stale so the retry rebuilds it (which releases
# the old link first - the 2026-09-24 bug).
srv = self.make_server(None)
def onecmd(cmd):
@@ -282,7 +323,7 @@ class AutoEquipTests(unittest.TestCase):
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)
self.assertTrue(server._TRANSPORT_STALE)
def test_transport_fatal_failure_marks_the_transport(self):
srv = self.make_server(None)
@@ -341,7 +382,7 @@ class AutoEquipTests(unittest.TestCase):
mock.patch.object(server, '_apply_equipped_card',
lambda s: self.fail('_apply_equipped_card must not run')):
self.assertFalse(server._auto_equip_attempt(srv))
self.assertFalse(server._TRANSPORT_STALE)
self.assertTrue(server._TRANSPORT_STALE)
def test_captured_equip_traceback_is_a_failure(self):
# cmd2 prints swallowed exceptions (with cmd2 debug: a traceback) to
@@ -358,6 +399,73 @@ class AutoEquipTests(unittest.TestCase):
lambda s: self.fail('_apply_equipped_card must not run')):
self.assertFalse(server._auto_equip_attempt(srv))
def test_a_card_present_init_without_a_card_is_a_failure(self):
# The Windows loop: the presence monitor still reports the card, the
# init produced nothing - it must retry (and rebuild), never report a
# silent "card gone" success that re-arms forever (2026-10-21).
srv = self.make_server(None)
def onecmd(cmd):
pass # cmd2 swallowed the real error: no card built
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_a_card_removed_during_the_init_is_still_a_silent_no_op(self):
srv = self.make_server(None)
def onecmd(cmd):
srv.card_present = False # the monitor saw the removal mid-init
srv.app.onecmd_plus_hooks = onecmd
with mock.patch.object(server, '_ensure_transport', lambda s: True), \
mock.patch('sys.stderr', new=StringIO()) as err:
self.assertTrue(server._auto_equip_attempt(srv))
self.assertIn('card gone during initialization', err.getvalue())
self.assertFalse(server._TRANSPORT_STALE)
def test_the_swallowed_traceback_reaches_the_log(self):
srv = self.make_server(None)
def onecmd(cmd):
srv.app.card = SimpleNamespace(_scc='scc')
sys.stderr.write('Traceback (most recent call last):\n'
' File "x", line 1, in equip\n'
'ProtocolError: Connection failed\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('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):
calls = []
srv = self.make_server(None)