From 643c6609f388018ba81bc8e8cd08b840d229e371 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Sat, 24 Oct 2026 00:43:39 +0300 Subject: [PATCH] test runner: add the stuck-card watchdog A card that stops answering (no SW, no driver error) leaves the runner inside a blocking SCardTransmit while it holds the card lock - Stop cannot take effect, the presence monitor waits for the lock and a load test hangs in that iteration without explanation. The runner now beats a progress heartbeat (each step start/finish and each attempt of a bounded STATUS/drain loop); after _TEST_STEP_TIMEOUT (30 s) the watchdog flags the run `stuck`, records the step and the idle time, sets the stop flag and tries to cancel the blocked transmit by disconnecting the PC/SC link on a daemon thread. The disconnect must run in the background: pcsc-lite serializes it behind the pending transmit (both take the per-context lock), so a synchronous call would block the watchdog instead of cancelling anything. Where a platform does cancel, the step fails through the ordinary on_fail path; either way the finalisers map `stuck` to `error` - a stuck card is not a user stop - and the pending drain is skipped on the dead link. The transport is marked stale once the disconnect returns, so the next equip rebuilds it. The run status gains `stuck` and `heartbeat`. Help EN/RU, docs/test-scripts.md, docs/api.md, the CHANGELOG (folded into the unreleased 3.37.0) and both AGENTS describe the behaviour and the background-disconnect caveat; the sw cache is simple-v476 (help is precached). Tests: tests/test_test_watchdog.py (17: the check rule, the abort, the final status of the single/load/suite runs and a blocked-runner integration case). 1058 Python + 1069 frontend. --- CHANGELOG.md | 16 ++ docs/api.md | 10 +- docs/test-scripts.md | 23 +++ frontend/help-ru.html | 2 +- frontend/help.html | 2 +- frontend/sw.js | 2 +- pysim_simple_server/server.py | 171 ++++++++++++++++-- tests/test_test_watchdog.py | 317 ++++++++++++++++++++++++++++++++++ 8 files changed, 527 insertions(+), 16 deletions(-) create mode 100644 tests/test_test_watchdog.py diff --git a/CHANGELOG.md b/CHANGELOG.md index aaf3849..8ebf3c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,22 @@ every change carries a descriptive commit message. `done`, `failed_iteration`, `elapsed_ms`, `avg_ms`); the count is a run parameter, not part of the stored script or suite. +- The **stuck-card watchdog** for test runs. A card that stops answering (no status + word, no driver error) used to leave the runner inside a blocking `SCardTransmit` + holding the card lock — *Stop* could not take effect, the presence monitor waited for + the lock and a load test hung in that iteration without explanation. After 30 s + without runner progress (a bounded STATUS/drain loop beats once per attempt) the + watchdog marks the run `stuck`, records `the card stopped responding (step N, no + answer for N s)`, sets the stop flag and **tries** to cancel the blocked transmit by + disconnecting the PC/SC link on a background thread. Where PC/SC can cancel it, the + step fails through the ordinary `on_fail` path and the run finalises as an **error** + (never `stopped`); where it cannot (pcsc-lite serializes the disconnect behind the + pending transmit), the status already names the step until the driver's own card + timeout returns. The transport is marked stale once the disconnect returns, so the + next *Equip* rebuilds it. The status gains `stuck`; the help EN/RU, + `docs/test-scripts.md` and `docs/api.md` describe the behaviour and the + background-disconnect caveat. + ### Fixed - The load-test loop closes properly on a raised runner error (it used to leave the run diff --git a/docs/api.md b/docs/api.md index 17003c9..79b99b5 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1039,7 +1039,15 @@ The current (or last) run: ``` `status` becomes `ok`/`warning`/`error`/`stopped` when the run finishes; -expected/actual pairs are reported per check. +expected/actual pairs are reported per check. A card that stops answering is +not waited on silently: after 30 s without runner progress the watchdog sets +`stuck: true`, `stop` and `error` (`the card stopped responding (step N, no +answer for N s)`) and tries to cancel the blocked transmit by disconnecting +the PC/SC link on a background thread (pcsc-lite serializes the disconnect +behind the pending transmit, so the call returns only when the driver's card +timeout fires). Once the step returns, the run finalises as `error` - a stuck +card is a failure, not a user stop - and the next equip rebuilds the link +(the transport is marked stale). ### `POST /api/test/stop` diff --git a/docs/test-scripts.md b/docs/test-scripts.md index 79fd4e1..9536e6c 100644 --- a/docs/test-scripts.md +++ b/docs/test-scripts.md @@ -242,6 +242,29 @@ between iterations is an error. The SCP80 counter state is shared across iterations, so packets continue from what the previous iteration consumed. The iteration count is a run parameter - the stored script/suite is unchanged. +## A card that stops answering + +pyscard's `SCardTransmit` is blocking and has no timeout: a card that stops +answering (no status word, no driver error) would leave the runner inside the +step while it holds the card lock - Stop could not take effect, the presence +monitor would wait for the lock, and a load test would hang in that iteration. +The runner's **watchdog** watches a progress heartbeat: after 30 s without one +(a bounded STATUS/drain loop beats once per attempt, so a long poll is not +mistaken for a stuck card) it marks the run `stuck`, sets the error +`the card stopped responding (step N, no answer for N s)` and the stop flag, +and **tries** to cancel the blocked transmit by disconnecting the PC/SC link +on a background thread. Where PC/SC can cancel it, the blocking call raises, +the step fails through the ordinary path (`on_fail` applies) and the run +finalises as an **error** - never as `stopped`, even though the stop flag +ended the loop. The disconnect runs in the background because pcsc-lite +serializes it behind the pending transmit (both take the per-context lock), so +on that stack the call returns only when the driver's own card timeout fires +(libccid derives it from the card's ATR); the run is already flagged and the +status names the step meanwhile. The transport is marked stale once the +disconnect returns, so the next **Equip** rebuilds the link. A physical card +removal is unaffected: the driver aborts the transmit and the presence monitor +clears the card session. + ## Document shape ```json diff --git a/frontend/help-ru.html b/frontend/help-ru.html index 626ec00..2cab622 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -571,7 +571,7 @@

8.9 Тест-скрипт

-

Тест-скрипт — упорядоченный список действий и ожиданий проактивных команд, выполняемый на подключённой карте на стороне сервера. Пока запуск активен, карта принадлежит скрипту: остальные операции с картой отклоняются (HTTP 409), фоновый опрос STATUS и автоматическая обработка проактивных команд приостанавливаются — объявленная картой команда остаётся ожидающей для скрипта, и оба механизма возобновляются по завершении запуска. Собственные BIP-команды карты (OPEN/SEND/RECEIVE/CLOSE CHANNEL, GET CHANNEL STATUS) — исключение: терминал отвечает на них по-настоящему: каналы продолжают работать во время запуска, а BIP-события приходят для каналов, открытых самой картой (управление BIP остаётся заблокированным) — если только respond шага не переопределяет ответ через raw TLV или результат, отличный от ok; отчёт о запуске хранится в памяти (в том числе после перезагрузки страницы) до нажатия Очистить отчёт. Скрипты хранятся в хранилище на сервере (~/.pysim-simple-server/test_scripts.json, --test-scripts), поэтому все браузеры работают с одним набором; изменения записываются в хранилище по мере правки (кнопки «Сохранить» нет — черновик без валидного шага остаётся локальным); экспорт/импорт JSON остаётся для резервных копий и переноса скриптов между стендами (старый экспорт из localStorage импортируется как есть).

+

Тест-скрипт — упорядоченный список действий и ожиданий проактивных команд, выполняемый на подключённой карте на стороне сервера. Пока запуск активен, карта принадлежит скрипту: остальные операции с картой отклоняются (HTTP 409), фоновый опрос STATUS и автоматическая обработка проактивных команд приостанавливаются — объявленная картой команда остаётся ожидающей для скрипта, и оба механизма возобновляются по завершении запуска. Собственные BIP-команды карты (OPEN/SEND/RECEIVE/CLOSE CHANNEL, GET CHANNEL STATUS) — исключение: терминал отвечает на них по-настоящему: каналы продолжают работать во время запуска, а BIP-события приходят для каналов, открытых самой картой (управление BIP остаётся заблокированным) — если только respond шага не переопределяет ответ через raw TLV или результат, отличный от ok; отчёт о запуске хранится в памяти (в том числе после перезагрузки страницы) до нажатия Очистить отчёт. Скрипты хранятся в хранилище на сервере (~/.pysim-simple-server/test_scripts.json, --test-scripts), поэтому все браузеры работают с одним набором; изменения записываются в хранилище по мере правки (кнопки «Сохранить» нет — черновик без валидного шага остаётся локальным); экспорт/импорт JSON остаётся для резервных копий и переноса скриптов между стендами (старый экспорт из localStorage импортируется как есть). Карта, переставшая отвечать, не подвешивает запуск молча: через 30 с без прогресса сторожевой таймер помечает запуск как ошибку с указанием шага, ставит флаг остановки и пытается прервать зависший обмен, разрывая связь PC/SC в фоновом потоке (связь помечается устаревшей, так что следующий Equip пересоздаёт её). Драйвер с собственным тайм-аутом карты завершает шаг обычным путём.

У вкладки четыре представления: список наборов (по умолчанию; Добавить набор / Редактировать / Запустить / Экспорт / Удалить), редактор набора (скрипты набора в порядке выполнения — необязательный setup первым и teardown последним, с пометками ролей; у каждого скрипта Запустить/Редактировать/Переместить/Скопировать/Удалить, политика при ошибке остановить (по умолчанию) / продолжить и Требуется ADM), редактор скрипта (список шагов; кнопка Старт выполняет только этот скрипт и показывает результат под шагами — он не сохраняется) и отчёт набора (после запуска набора: статус каждого скрипта, таблица шагов с ожидаемым/фактическим, примечание об ошибке и выдержка из журнала запуска; сводка с успешно/предупреждения/ошибки/пропущено, время выполнения, счётчики SCP80 до → после по каждому keyset, пресет и предупреждение о смене сессии карты; экспорт в JSON или Markdown). Запуск набора выполняет его скрипты по порядку в одной сессии карты, без сбросов и без продолжения с места остановки; при ошибке решает, остановится ли набор на этом скрипте или запустится следующий, teardown выполняется всегда при остановке или ошибке (пока карта в ридере), чтобы следующий тест начался с чистого состояния, а сброс карты останавливает набор и отмечается в отчёте. Начать со сброса карты сбрасывает карту и выполняет полную переинициализацию перед запуском набора (скрипты затем работают со свежеинициализированной картой); пресет должен по-прежнему совпадать с ICCID карты, а требуемый ADM проверяется заново для новой сессии карты. Инициализация поглощает начальные proactive-команды карты, поэтому скрипт не поймает первую команду после сброса (они остаются в журнале proactive). Требуется ADM (у набора или скрипта) проверяет ключ ADM из подобранного пресета один раз перед запуском — ошибка отклоняет запуск (проверьте вручную через значок ADM; автоматических повторов нет). Скрипты хранятся в хранилище наборов (~/.pysim-simple-server/test_suites.json, --test-suites); каждый скрипт принадлежит ровно одному набору, и скрипт можно переместить или скопировать в другой набор (экспорт набора — это пакет с набором и его скриптами; старый экспорт только скриптов импортируется в автоматически созданный набор «Imported scripts»).

Прогон можно повторить: поле «Итерации» рядом с Start / Run suite выполняет скрипт или весь набор указанное число раз — это нагрузочный тест. Успешные итерации не сохраняются: в статусе видны только счётчик (итерация k / N) и время, журнал шагов остаётся пустым, а память не растёт при любом числе итераций. Первая итерация, завершившаяся ошибкой, останавливает цикл и сохраняет свой полный отчёт (шаги, неудачные проверки, фрагмент журнала, отчёт по скриптам набора); Остановить завершает работу после текущей итерации, а смена карты между итерациями считается ошибкой (следующая карта никогда не запускается со скриптом другой карты). Число итераций — параметр прогона, сохранённый скрипт/набор не меняется; счётчики SCP80 продолжаются с того значения, которое израсходовала предыдущая итерация.