Skip to content

Research: OWN001 [disposable field] ignores disposal in another partial file (*.Designer.cs) — bounded experiment #410

Closed

Description

@PhysShell

Статус

Исследовательский эксперимент (bounded), а не одобренная фича. Решение о production-изменении принимается только после gate G1–G4.

Наблюдение

При разметке находок в #407 восемь из 22 ложных OWN001 [disposable field] оказались полями, которые освобождаются в другом partial-файле того же класса:

Репозиторий Поле Где освобождается
gitextensions RepoObjectsTree._branchesTree, _remotesTree, _tagTree, _submoduleTree, _stashTree, _worktreeTree (6) RepoObjectsTree.Designer.cs
gitextensions FormAddToGitIgnore._ignoredFilesLoader FormAddToGitIgnore.Designer.cs
gitextensions FileStatusList._customDiffToolsSequence (объявлено в FileStatusList.ContextMenu.cs) FileStatusList.cs, лямбда Disposed += … в конструкторе

Предполагаемые механизмы — гипотезы, проверяются в G1:

  1. IsGenerated() (Program.cs:222) безусловно пропускает *.Designer.cs при разборе входа: эти файлы не попадают в компиляцию, и доказательство освобождения в них невидимо.
  2. Проход по полям собирает освобождения внутри одного ClassDeclarationSyntax, а не во всех partial-объявлениях класса.

Ограничения эксперимента

  • *.Designer.cs не включаются в обычную генерацию находок. Они читаются только как дополнительный источник доказательств освобождения.
  • Освобождение засчитывается только при конкретной связи с полем: вызов Dispose на этом поле, связанный по символу. components.Dispose() и Dispose «чего-нибудь» не освобождают произвольное поле класса.
  • Компиляция с учётом проекта (Extractor: project-aware semantic resolution — types from SDK implicit usings do not resolve #407) не строится.
  • Бюджет: минимальное воспроизведение, восемь известных случаев и не больше одного небольшого PoC.

Gates

Gate Эксперимент KILL
G1 Минимальный partial-класс: поле в Foo.cs, Dispose в Foo.Designer.cs; отдельно — в обычном partial-файле причина предупреждения не в пропущенном файле или partial-объявлении
G2 Восемь реальных FP выше большая часть не объясняется доступным доказательством освобождения
G3 Негативные контроли: одноимённый чужой класс, другое поле, Dispose в неисполняемом месте появляются необоснованные подавления
G4 Сравнение корпуса; настоящие утечки остаются регрессии вне целевой группы

GO на рассмотрение production-исправления: сняты 6–8 подтверждённых FP без потери настоящих предупреждений. Иначе — KILL.

Связано

#407 (источник наблюдения; остаётся deferred).

Activity

  1. PhysShell commented on Oct 18, 2026

    @PhysShell
    OwnerAuthor

    Итог эксперимента, раунд 1

    Статус: GO на ограниченную доработку PoC, production gate pending G3.

    Критерий Результат
    G1, причина PASS. Главная причина — освобождения ищутся только внутри одного объявления класса (ClassDeclarationSyntax). Пропуск *.Designer.cs (IsGenerated) — вторая причина, только для designer-файлов.
    Вариант A: designer-файл в компиляции KILL по G4
    Вариант B: известные FP PASS
    G3, идентичность поля PASS
    G3, достижимость освобождения не пройден
    G4, корпус PASS по проверенным случаям

    G1

    • Если Dispose перенести в файл с другим именем, который попадает в компиляцию, OWN001 всё равно остаётся.
    • То же происходит, когда освобождение находится в обычном partial-файле: в лямбде Disposed +=.
    • Если освобождение лежит в том же объявлении класса, OWN001 исчезает.

    Вариант A — KILL

    Designer-файл добавлялся в компиляцию только как источник доказательств, без генерации фактов. На gitextensions с global using вариант снимает 8 целевых FP. Но designer-файлы разрешают типы дочерних контролов, и 43 OWN050 превращаются в 75 новых предупреждений о неотписанных событиях дочерних контролов. Скорее всего это FP, и это регрессия вне целевой группы.

    Вариант B

    • Другие partial-объявления. Освобождение засчитывается, только если получатель по символу — поле этого класса.
    • Designer-файлы. Файл Foo.Designer.cs рядом с Foo.cs разбирается, но не компилируется. Класс сопоставляется по полному имени, поле ищется среди членов класса, перекрытие локалом или параметром исключено.

    G2. На gitextensions с тем же GlobalUsings.cs, что в #407, сняты ровно 8 находок — все 8 размечены там как FP. Новых находок 0.

    G3, идентичность поля — PASS. OWN001 остаётся во всех контрольных случаях:

    • одноимённое поле другого класса;
    • освобождается другое поле;
    • освобождается поле другого экземпляра;
    • поле перекрыто локалом;
    • designer-файл в другом пространстве имён.

    G3, достижимость — не пройден. Dispose в невызываемом методе другого partial-файла подавляет OWN001. main ведёт себя так же внутри одного объявления, но это не оправдывает распространение ошибки на partial-файлы.

    G4. На корпусе из 144 деревьев без global using 140 деревьев побайтно совпадают с main. В остальных снято 5 уникальных находок, добавлено 0. Все 5 проверены по исходнику: поле освобождается в другом partial-файле.

    Репозиторий Поле Где освобождается
    honua-server FileBackedLicenseService._operationCancellation FileBackedLicenseService.cs:87
    garnet ClusterManager.ctsGossip ClusterManager.cs:167
    garnet ReplicationManager.resetHandler ReplicaDiskbasedSync.cs:211
    rabbitmq-dotnet-client AutorecoveringConnection._recoveryCancellationTokenSource AutorecoveringConnection.cs:368
    rabbitmq-dotnet-client Connection._mainLoopCts Connection.cs:767

    Итого: снято 13 подтверждённых FP в 4 репозиториях (8 — в условиях #407, 5 — на обычном корпусе). Новых находок нет, настоящих предупреждений не потеряно.

    Следующий шаг

    Сузить подавление до доказанных путей жизненного цикла. Нужно проверить три случая, где освобождение лежит в другом partial-файле:

    1. освобождение из настоящего метода жизненного цикла — OWN001 должен исчезнуть;
    2. освобождение только в невызываемом методе — OWN001 должен остаться;
    3. освобождение за условием, которое не гарантировано, — OWN001 должен остаться.

    Часть из 13 снятых FP при этом может вернуться; это допустимо.

    PoC не закоммичен.

  2. PhysShell commented on Oct 18, 2026

    @PhysShell
    OwnerAuthor

    Раунд 2: достижимость освобождения

    Статус: G1–G4 PASS; GO for production PR (заменяет «production gate pending G3»). Merge — только после регрессионных тестов и CI.

    Что показал G3

    В раунде 1 G3 был провален сильнее, чем сообщалось. Чтобы это проверить, взяты 14 случаев: поле объявлено в Foo.cs, а освобождается в другом файле. Вариант B раунда 1 подавлял предупреждение во всех 8 случаях, где оно должно остаться, а не только для невызываемого метода.

    Как сужено

    partial-объявления. Освобождение засчитывается, только если:

    • оно находится в доказанном контексте завершения. Используется уже существующая модель TeardownContextMethods / InTeardownContext — на ней работают проверки -= и Stop(). Подходят Dispose/DisposeAsync/OnClosed…, всё, что из них вызывается по разрешённым вызовам внутри того же объявления, и лямбда, подписанная на собственный Disposed/Closed;
    • каждое охватывающее условие — это положительный if (disposing) или проверка того же поля на null. Ветка else, флаг или цикл не подходят.

    designer-файлы. Вызов должен стоять прямо в теле метода завершения, без лямбд и помощников. disposing должен быть собственным bool-параметром этого метода. Ограничения на условия те же.

    Результаты

    Случай main раунд 1 раунд 2 ожидание
    P1 Dispose() в partial WARN — — —
    P2 невызываемый UnusedCleanup() WARN — WARN WARN
    P3 if (_enabled) в Dispose WARN — WARN WARN
    P4 Dispose() → Cleanup() WARN — — —
    P5 лямбда на собственный Disposed WARN — — —
    P6 if (_f != null) WARN — — —
    P7 лямбда на Disposed чужого объекта WARN — WARN WARN
    P8 неподписанная лямбда в Dispose WARN — WARN WARN
    D1 designer if (disposing) { _f.Dispose(); } WARN — — —
    D2 designer, невызываемый метод WARN — WARN WARN
    D3 designer if (!disposing) WARN — WARN WARN
    D4 designer, ветка else WARN — WARN WARN
    D5 designer if (_enabled) WARN — WARN WARN
    D6 designer, лямбда в Dispose WARN — WARN WARN

    G3. Раунд 2 проходит все 14 случаев. Контроли идентичности поля тоже проходят: чужой класс, другое поле, другой экземпляр, перекрывающий локал, другое пространство имён.

    G2. gitextensions с тем же GlobalUsings.cs, что в #407: сняты те же 8 размеченных FP, новых находок 0.

    G4. На 144 деревьях без global using 140 побайтно совпадают с main. Снято 4 находки вместо прежних 5, добавлено 0.

    • Вернулось предупреждение на garnet ReplicationManager.resetHandler. Поле освобождается и пересоздаётся на пути восстановления реплики (ReplicaDiskbasedSync.cs:211), а не при завершении владельца, поэтому предупреждение остаётся обоснованно.
    • Остались снятыми:
      • honua _operationCancellation — Dispose после if (_disposed) return;;
      • garnet ctsGossip — DisposeBackgroundTasks, вызывается из завершения;
      • rabbitmq _recoveryCancellationTokenSource и _mainLoopCts — пути Dispose/finally при закрытии.

    Итого: 12 подтверждённых FP снято в 4 репозиториях, новых находок нет.

    Сознательные ограничения

    Модель остаётся консервативной:

    • цепочка вызовов не выходит за пределы одного partial-объявления;
    • в designer-файлах помощники не учитываются.

    Часть FP поэтому останется, но необоснованного доказательства освобождения модель не даёт.

    Результат не относится к #407: он показывает, что OWN001 можно сделать точнее, но не доказывает, что компиляция с учётом проекта окупается.

  3. added a commit that references this issue on Oct 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    questionFurther information is requested

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions