diff --git a/CHANGELOG.md b/CHANGELOG.md index 731454f..51ef04a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,29 @@ 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 diff --git a/README.md b/README.md index e362c20..fbfc521 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** — 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. 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 109b0f0..b65923c 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 не срабатывает после смены карты** — авто-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 diff --git a/frontend/index.html b/frontend/index.html index 072b961..040328c 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.2'; +const SIMPLE_VERSION = '3.32.4'; 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 26a9c6d..887da9e 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v460'; +const CACHE = 'simple-v462'; const URLS = [ 'index.html', 'help.html', diff --git a/pyproject.toml b/pyproject.toml index 8357b35..8855c0c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "3.32.2" +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" diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py index 31122b1..5297507 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.2' +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: @@ -5744,8 +5745,15 @@ def _auto_equip_attempt(server): 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(lines[start:start + 12])) + % '\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 @@ -5819,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() diff --git a/tests/test_card_monitor.py b/tests/test_card_monitor.py index b3901fe..a545a21 100644 --- a/tests/test_card_monitor.py +++ b/tests/test_card_monitor.py @@ -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 diff --git a/tests/test_card_recovery.py b/tests/test_card_recovery.py index 6ff3a53..8dea91d 100644 --- a/tests/test_card_recovery.py +++ b/tests/test_card_recovery.py @@ -444,6 +444,28 @@ class AutoEquipTests(unittest.TestCase): 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)