Skip to content

fix: транзакционный путь ошибки инициализации контролов + throw; (#109)#114

Merged
EvaTheSalmon merged 3 commits into
masterfrom
feature/109
Jun 5, 2026
Merged

fix: транзакционный путь ошибки инициализации контролов + throw; (#109)#114
EvaTheSalmon merged 3 commits into
masterfrom
feature/109

Conversation

@EvaTheSalmon

Copy link
Copy Markdown
Member

Closes #109.

Что сделано

Путь обработки ошибки инициализации обоих визуальных контролов (TableControl, MbeTableEditorControl) сделан транзакционным: HandleInitializationError теперь делает LogCriticalCleanupRuntimeState()MessageBox, после чего исключение пробрасывается на месте catch через throw; (а не throw ex;). Каждая попытка инициализации теперь либо завершается полностью, либо полностью откатывается с безопасным повторным входом.

Конкретные правки:

  • CleanupRuntimeState() перед пробросом — откат частично построенного состояния. Вызов сбрасывает _runtimeInitialized через ResetRuntimeFields, снимает подписки и освобождает компоненты до того, как модальный MessageBox начнёт качать сообщения; верифицирован как идемпотентный и безопасный на частичном состоянии.
  • throw; вместо throw ex; в обоих контролах — сохраняется оригинальный стек-трейс. Проброс уходит за COM-границу в нативный движок через голые (без try/catch) платформенные тела, поэтому уничтоженный трейс был бы единственной диагностикой.
  • Наблюдение faulted-задачи в no-handle ветке TryReadFromPlc (только TableControl — в editor-варианте TryReadFromPlc нет): логика вынесена в юнит-тестируемый хелпер TaskFaultLogger.LogOnFault (OnlyOnFaulted, TaskScheduler.Default, лог распакованного inner exception). Обычный недостижимый-ПЛК случай уже логируется нижними слоями; хелпер закрывает лишь по-настоящему faulted-задачу, которую иначе не наблюдает никто.
  • Поштучная изоляция disposals в DisposeRuntimeComponents обоих контролов (каждый в своём SafeDisposal.RunAll) — бросок в одном Dispose больше не оставляет остальные компоненты подписанными на DI-singleton'ы; найдено ревью, тот же класс утечки, который закрывает PR.
  • Инвариант-комментарии на месте вызова MarkInitialized() обоих контролов; инвариант транзакционной инициализации зафиксирован в Docs/architecture/architecture.md (раздел rendezvous protocol).

Скорректированный анализ

Перед реализацией issue был проверён против кода, доков по архитектуре и декомпилированного SDK MasterSCADA 3.12. Анализ скорректирован, чтобы история issue не вводила в заблуждение:

  • Problem 1 («залипшая частичная инициализация через tail-шаги») — структура подтверждена, но названный триггер опровергнут. Указанный путь исключения недостижим: _ = _presenter.ReceiveRecipeAsync() не может бросить синхронно — это async Task-метод, и компилятор захватывает все исключения тела (включая код до первого await) в возвращаемую задачу, а сбой отброшенной задачи до внешнего catch не доходит. BusyStateManager.Enter() — это Interlocked.Increment плюс тривиальная аллокация, бросать нечему. ApplyInitialPermissions уже полностью обёрнут в собственный try/catch. Предложенный в issue локальный try/catch вокруг tail-шагов адресует мёртвый путь и не реализован.
  • Реальный дефект — нетранзакционная инициализация. Исключение до MarkInitialized (классический NRE из-за рассогласования pin/XML, Docs/known_issues/09) пробрасывалось без очистки, при _runtimeInitialized == false. Две независимые точки входа — put_DesignMode и OnFBLinkChanged — могут сработать в рамках одного времени жизни контрола, а по декомпилированному SDK OnFBLinkChanged поднимается безусловно на каждом присвоении RID. Повторный вход проходит guard и заново прогоняет one-shot шаги, давая: (1) реальную двойную подписку OnPermissionsChanged на singleton-е StateProvider (единственный -= снимает только одну); (2) осиротевшие pass-1 инстансы TableRenderCoordinator / TablePresenter, чьи подписки на DI-singleton'ы текут до смерти контейнера FB. Транзакционный откат закрывает оба канала.

Тесты

+3 теста на TaskFaultLogger (faulted → залогирован один раз с распакованным исключением; completed/canceled → не логируется; детерминированно через await континуации, без поллинга; recording-fake + FluentAssertions по конвенции сьюта). Итого 293 зелёных. Остальные изменённые строки — lifecycle-glue контролов, требующий живого FBConnector/хоста и не конструируемый в тест-проекте; для них регрессионный гейт — существующий набор. Ручная проверка в MasterSCADA пройдена: форсированный сбой инициализации → откат → чистое переоткрытие, оба контрола.

🤖 Generated with Claude Code

EvaTheSalmon and others added 2 commits June 5, 2026 13:21
Make initialization of both visual control shells (TableControl,
MbeTableEditorControl) transactional: HandleInitializationError now rolls
back via CleanupRuntimeState before notifying the operator, and the catch
site rethrows with `throw;` to preserve the original stack trace. A second
init entry (put_DesignMode / OnFBLinkChanged) therefore retries from a clean
slate instead of double-subscribing OnPermissionsChanged or leaking orphaned
coordinator/presenter instances.

Add an invariant comment at the MarkInitialized call site documenting why the
flag is set before the tail steps and why those steps must not throw. The
no-handle branch of TryReadFromPlc (TableControl only) now logs a faulted
discarded ReceiveRecipeAsync task via the extracted, unit-tested
TaskFaultLogger; the architecture invariant is recorded in architecture.md.

Closes #109

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Record the implementation plan and the multi-agent verification findings:
Problem 1's named trigger refuted, the real defect identified as
non-transactional control init, and the chosen transactional-rollback fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@EvaTheSalmon EvaTheSalmon left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ревью прошло: все заявления в описании PR подтверждены по коду.

  • Сборка чистая, 293/293 теста зелёные, dotnet format без замечаний по затронутым файлам.
  • CleanupRuntimeState идемпотентен и не бросает (всё через SafeDisposal.RunAll, ResetRuntimeFields — чистое обнуление полей), так что throw; гарантированно доносит оригинальное исключение до движка.
  • Оба контрола расходятся ровно на документированную разницу TryReadFromPlc — без скрытой дивергенции.

Одно мелкое замечание, не блокер:

TableControl.Lifecycle.cs:160-170 — ветка IsHandleCreated в TryReadFromPlc глотает фолт через catch { /* ignored */ }, тогда как no-handle ветка теперь логирует через TaskFaultLogger. Один и тот же сбой в одной ветке молчит, в другой логируется. Либо комментарий, почему глотание намеренное, либо унифицировать обе ветки на TaskFaultLogger.

Review on #114: the has-handle branch swallowed faults via an empty
catch in an async void lambda while the no-handle branch logged through
TaskFaultLogger. Both branches now share StartInitialPlcRead, which
snapshots _presenter (the BeginInvoke-posted call runs after cleanup
may have nulled the field) and observes the task via TaskFaultLogger.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@EvaTheSalmon

Copy link
Copy Markdown
Member Author

Применено в 8182d70: обе ветки TryReadFromPlc унифицированы на общий StartInitialPlcReadTaskFaultLogger.LogOnFault. Попутно ушли async void-лямбда и ConfigureAwait(true); добавлен снапшот _presenterBeginInvoke-делегат исполняется позже, и к этому моменту cleanup мог обнулить поле (раньше этот NRE молча глотался пустым catch, теперь гонка закрыта явно).

293/293 зелёные, формат чистый.

@EvaTheSalmon EvaTheSalmon merged commit 3fa6a74 into master Jun 5, 2026
1 check passed
@EvaTheSalmon EvaTheSalmon deleted the feature/109 branch June 5, 2026 10:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Липкая частичная инициализация контрола при сбое в хвостовых шагах + throw ex в error path

1 participant