diff --git a/CHANGELOG.md b/CHANGELOG.md index b714250..731454f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ every change carries a descriptive commit message. ## [Unreleased] +## [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 diff --git a/README.md b/README.md index a5eef27..e362c20 100644 --- a/README.md +++ b/README.md @@ -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** — 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. ### API reference diff --git a/README_RUS.md b/README_RUS.md index 0defa5b..109b0f0 100644 --- a/README_RUS.md +++ b/README_RUS.md @@ -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 не срабатывает после смены карты** — авто-equip повторяется до 3 раз, и **каждая неудачная попытка пересоздаёт транспорт PC/SC** перед следующей: после смены карты в Windows старое соединение может остаться неспособным подключиться, из-за чего раньше все повторы падали, а сторож перезапускал попытки бесконечно. Проглоченная ошибка инициализации (с трассировкой) теперь печатается как `AUTO-EQUIP: init output:`, а «карта есть, но карта не создана» — это ошибка, а не тихое «карта пропала». Сторож повторяет с удвоением задержки (5 с … 60 с), пока карта в ридере; снятие карты полностью очищает состояние, поэтому обработчики отвечают «нет карты», а не шлют APDU мёртвой карте. ### Справочник API diff --git a/frontend/index.html b/frontend/index.html index a0d82c5..072b961 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -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.2'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== view history (the browser Back/Forward buttons) ===== diff --git a/frontend/sw.js b/frontend/sw.js index 7ba7b4e..26a9c6d 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v459'; +const CACHE = 'simple-v460'; const URLS = [ 'index.html', 'help.html', diff --git a/pyproject.toml b/pyproject.toml index 883a662..8357b35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "3.32.1" +version = "3.32.2" description = "HTTP REST server wrapping pysim for the SIMple PWA" license = { text = "GPL-2.0-or-later" } requires-python = ">=3.9" diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py index fe42b38..31122b1 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -37,7 +37,7 @@ from osmocom.tlv import BER_TLV_IE from osmocom.utils import rpad -VERSION = '3.32.1' +VERSION = '3.32.2' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE @@ -5701,8 +5701,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 +5716,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 +5737,24 @@ 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) + sys.stderr.write('AUTO-EQUIP: init output:\n%s\n' + % '\n'.join(lines[start:start + 12])) + # 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: diff --git a/tests/test_card_recovery.py b/tests/test_card_recovery.py index 8863732..6ff3a53 100644 --- a/tests/test_card_recovery.py +++ b/tests/test_card_recovery.py @@ -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,51 @@ 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_failed_attempt_unequips_the_half_initialized_shell(self): calls = [] srv = self.make_server(None)