BUG (#20/PR #50): apply — BaseException не покрыт (нет revert и failed-записи аудита); TOCTOU в set_geoip_schedule #55
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Найдено на ревью PR #50 (head
7e10ffaf8ad436f10b4195fddc4120ce576b2c7d). Не блокирует merge само по себе, но правки нужны в этом же цикле. Refs #20, #50.D-1 (средне):
except Exceptionвокруг apply не покрываетBaseExceptionApplyEngine.applyловитException. ПриBaseException(в asyncio этоasyncio.CancelledError, плюсKeyboardInterrupt/SystemExit) транзакция БД откатывается (contextmanager ловитBaseException), но:Applier.revert(previous)не вызывается → внешняя подсистема (awg/iptables) может остаться в новом состоянии, пока БД уже откатилась;audit_logне пишется терминальная записьfailed→ остаётся «висящая» записьstagedбез исхода,ApplyError(restored=…)наружу не поднимается.Фактический вывод пробы (внешний применитель поднимает
KeyboardInterrupt):Ожидаемое: любое прерывание применения конфигурации должно приводить к
revertвнешней подсистемы и к записиfailedв аудит (минимум — не оставлятьstagedбез исхода); отмена запроса не должна оставлять расхождение БД и iptables.Проверка: тест с применителем, поднимающим
BaseException/CancelledError— ожидаетсяrevertвызван и в аудите естьfailed.D-2 (низко): TOCTOU в
Store.set_geoip_scheduleРасписание читается (
self.get_geoip_schedule()) до открытия транзакции, затем пишетсяINSERT ... ON CONFLICT DO UPDATE. При конкурентном обновленииold_valueв аудите может быть устаревшим: журнал заявит не то «старое» значение, которое реально было заменено. Мутацииupsert_*/mark_config_appliedчитают и пишут внутри одной транзакции — там проблемы нет; здесь нужно перенести чтение внутрьwith self._db.transaction().Проверка: тест «два конкурентных
set_geoip_schedule→ каждое изменение имеет корректный old_value, журнал не противоречит состоянию».Воспроизведено пробой F1 (
tools/probe_qa.py) в воркспейсе задачи t_2dec61bb; полный отчёт —qa_report_pr50.md.Исправлено в
feat/20-storage@c4661c9db97ce4ed2c2c3471af06b18b69d99162D-1 —
BaseExceptionтеперь покрытApplyEngine.apply(apply.py):_rollback_failed_applyпринимаетBaseException,Applier.revert(previous)вызывается всегда (best-effort: падение revert не мешает восстановить БД; причина падения revert добавляется вdetail, а исходное исключение не теряется), затем в одной транзакции восстанавливается состояние и пишется записьapply/failed. Сверка состояния после отката и_last_failed_audit_idчитают черезDatabase.read().Тест:
test_base_exception_from_applier_reverts_and_records_failure[KeyboardInterrupt | SystemExit | asyncio.CancelledError]— ожидания:pytest.raises(<тот же BaseException>),reverted == [previous],capture_state == before, верхняя запись аудитаstatus='failed'с именем типа исключения вdetail,new_value == previous.Старые сценарии не сломаны:
ApplyError(restored=True)дляException, падение revert всё равно восстанавливает БД (test_failed_apply_with_failing_revert_still_restores_db— теперь ещё и с причиной вdetail).D-2 — TOCTOU в
set_geoip_schedulerepository.py: чтение текущего расписания перенесено внутрьwith self._db.transaction(), значение для аудита (current) — тот же снимок, который заменяется;new_value— то же значение, что записано в БД (включаяupdated_at).Тест:
test_concurrent_geoip_updates_keep_audit_chain_consistent— два потока одновременно вызываютset_geoip_schedule; проверяется: 2 записи аудита,first.old_value.cron == исходное,first.new_value.cron == second.old_value.cron,second.new_value.cron == get_geoip_schedule().cron, множество новых значений == оба запрошенных cron. На7e10ffafтест падает ('0 2 * * *' != '0 4 * * *'— устаревший old_value), наc4661c9d— проходит.Гейт на новом head: ruff check/format чисто, mypy strict чисто, pytest 105 passed (PR-only, coverage 97.79 %) / 107 passed с файлами #47 (97.80 %); пробы из ревью —
probe_qa.py40/40,probe_conc2.py0 крашей/потерь.QA (ревью раунд 2, PR #50 @
c4661c9d): D-1 и D-2 закрыты.D-1 (
BaseException): собственная проба наKeyboardInterrupt,SystemExit,asyncio.CancelledError— все три: тип исключения проброшен без изменения,Applier.revert(previous)вызван, БД восстановлена побайтово, вaudit_logровно однаfailed-запись с причиной вdetail, «висящих»stagedбез исхода нет. Мутация «except ExceptionвместоBaseException» → все три параметра теста падают (reverted == []).D-2 (TOCTOU
set_geoip_schedule):old_valueчитается внутри транзакции; 4 конкурентных обновления → цепочкаold→newсвязна, итоговое состояние == последнийnew. Мутация «чтение вне транзакции» в 1 из 2 прогонов ломает тест (разрыв цепочки) — тест чувствителен к регрессии, хотя гонка узкая.Гейт на merge-состоянии (
main063ecf18+ PR): ruff/format чисто, mypy strict 21 файл чисто, 107 passed, coverage 97.80 %.Merge — после #17 (порядок #17 → #50); issue закрывается владельцем.
Закрыто: исправлено в PR #50 (head
c4661c9d), влито вmain.applyтеперь ловитBaseException— cleanup выполняется всегда:Exception→ApplyError,KeyboardInterrupt/SystemExit/CancelledError→Applier.revert+ записьfailedв аудите + проброс исходного типа. Тест:test_base_exception_from_applier_reverts_and_records_failure[KeyboardInterrupt/SystemExit/CancelledError]; проверено ревьюером (revert вызван,failedв аудите, состояние БД восстановлено).old_valueвset_geoip_scheduleчитается внутри транзакции — цепочкаold→newсвязна при конкурентных обновлениях. Тест:test_concurrent_geoip_updates_keep_audit_chain_consistent(4 конкурентных обновления).Мутационный откат каждого фикса валит соответствующие новые тесты.