Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a08f642d4 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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) =====
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'simple-v459';
|
||||
const CACHE = 'simple-v460';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user