Skip to content

Safer controller dispose: preserve state and aggregate exceptions#16

Open
imurashka wants to merge 1 commit into
PlaytikaOSS:mainfrom
imurashka:ivan/log-dispose-exception
Open

Safer controller dispose: preserve state and aggregate exceptions#16
imurashka wants to merge 1 commit into
PlaytikaOSS:mainfrom
imurashka:ivan/log-dispose-exception

Conversation

@imurashka

@imurashka imurashka commented Jan 12, 2026

Copy link
Copy Markdown
Contributor

A controller could stay in a non-Disposed state when a disposable threw during dispose, leaving the pooled child list unreleased. A single dispose exception was also thrown unwrapped, so callers had to handle different exception shapes.

Dispose now always completes the cleanup and always surfaces dispose exceptions as one AggregateException. The controller ends in Disposed, and callers handle a single exception type.

@imurashka imurashka marked this pull request as draft January 12, 2026 16:53
@imurashka imurashka marked this pull request as ready for review January 12, 2026 17:33
Comment thread src/ControllersTree/Core/Utils/ControllerCompositeDisposable.cs Outdated

@eldarmguseynov eldarmguseynov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thank you for the fix. What do you think about optimizations around disposables?

@imurashka

Copy link
Copy Markdown
Contributor Author

Thank you for the fix. What do you think about optimizations around disposables?

I will be honest with you, I didn't think about optimization here too much. Right now, it looks pretty optimized, as I see. Espesially after small tweaks you proposed in method DisposeMany and ControllerBase class.

@imurashka

Copy link
Copy Markdown
Contributor Author

@eldarmguseynov hello! Is there any chance that you will merge this anytime soon?

@perepechenko

Copy link
Copy Markdown
Collaborator

Related to https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1065 Dispose should not throw any exception.

As for the use of Debug.LogError, many projects use their own logger, and we avoided using Debug.LogError in the controllers core.

The idea of a lazy composite disposable object is good, and we can use pooling instead of new.

@eldarmguseynov what do you think?

@imurashka

imurashka commented Feb 25, 2026

Copy link
Copy Markdown
Contributor Author

hey, @perepechenko, thanks for the review!

so here is what i changed based on it:

Debug.LogException removed from core. replaced with a static event ControllerBase.UnobservedDisposeException. projects subscribe to it on app start and route exceptions wherever they want — Debug.LogException, crashlytics, custom logger, whatever.

went with static event on ControllerBase because it's simple and follows existing patterns in .NET and Unity — like TaskScheduler.UnobservedTaskException, UniTaskScheduler.UnobservedTaskException, Application.logMessageReceived. the event lives on the main type users interact with, so it's easy to discover. no need to pass handler instances through constructors or factories for exactly this case.

about lazy composite disposable — i added it, then removed it. Initialize() always calls AddDisposable(_lifetimeTokenSource) which triggers composite creation anyway. so lazy init saves zero allocations in practice. rolled it back to keep code simpler.

about pooling composite disposable — i think it makes sense but want to do it in a separate PR to keep this one focused on the exception handling change.

also fixed a bug in DisposeInternal — if dispose threw, ListPool.Release and _state = Disposed were skipped (no finally block). now fixed.

@imurashka imurashka changed the title Log dispose exceptions to unity logger Add customizable dispose exception handler Feb 25, 2026
@imurashka imurashka force-pushed the ivan/log-dispose-exception branch 2 times, most recently from db32819 to d4c33eb Compare February 25, 2026 15:51
@imurashka imurashka force-pushed the ivan/log-dispose-exception branch from d4c33eb to 43701e6 Compare March 5, 2026 15:57
@coderabbitai

coderabbitai Bot commented Mar 5, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@imurashka, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 52 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 570f1277-de20-4492-a9bd-8f0db3c7bb67

📥 Commits

Reviewing files that changed from the base of the PR and between 489a8a5 and 38bc520.

📒 Files selected for processing (4)
  • src/ControllersTree/Core/Controllers/ControllerBase.cs
  • src/ControllersTree/Core/Utils/ControllerCompositeDisposable.cs
  • src/ControllersTree/Tests/ControllerCompositeDisposableTests.cs
  • src/ControllersTree/Tests/ControllersWithResultBaseTests.cs
📝 Walkthrough

Walkthrough

Adds disposal exception aggregation and handling across controller disposal paths: new ControllerDisposeAggregateException, utilities to execute and flatten exceptions (ExecuteAndCollectExceptions, CollectExceptions), event UnobservedDisposeException, integration into DisposeInternal and ControllerCompositeDisposable, expanded unit tests, metadata, and an added README section (duplicated).

Changes

Cohort / File(s) Summary
Exception handling core
src/ControllersTree/Core/Controllers/ControllerBase.Exceptions.cs
Introduces UnobservedDisposeException event, RaiseUnhandledDisposeException, ExecuteAndCollectExceptions, and CollectExceptions to flatten AggregateExceptions and collect leaf exceptions while raising events for unobserved leaves.
Controller disposal integration
src/ControllersTree/Core/Controllers/ControllerBase.cs
DisposeInternal now uses a pooled exception list and ExecuteAndCollectExceptions for lifetime token cancellation and composite disposal; throws ControllerDisposeAggregateException if any leaves collected; finally block always releases children and sets state.
Composite disposable changes
src/ControllersTree/Core/Utils/ControllerCompositeDisposable.cs
Adds ControllerDisposeAggregateException type; new public Add and AddRange methods; refactors disposal to DisposeMany(IReadOnlyCollection<IDisposable>) and uses ExecuteAndCollectExceptions to collect exceptions instead of immediate propagation.
Tests
src/ControllersTree/Tests/ControllerBaseExceptionsTests.cs, src/ControllersTree/Tests/ControllerCompositeDisposableTests.cs, src/ControllersTree/Tests/ControllersWithResultBaseTests.cs
Adds comprehensive tests for exception collection/flattening and event raising; updates composite-disposable tests to assert new aggregate exception behavior; adjusts one test to expect AggregateException.
Metadata & config
src/ControllersTree/Core/AssemblyInfo.cs, src/ControllersTree/Core/Controllers/ControllerBase.Exceptions.cs.meta, src/ControllersTree/Tests/ControllerBaseExceptionsTests.cs.meta, README.md
Adds InternalsVisibleTo for test assembly; adds .meta files for new tests; inserts “Handling Dispose Exceptions” section into README (appears duplicated).

Sequence Diagram

sequenceDiagram
    participant Caller as Client
    participant CB as ControllerBase
    participant Pool as ListPool<Exception>
    participant ECAE as ExecuteAndCollectExceptions
    participant COL as CollectExceptions
    participant Handler as UnobservedDisposeExceptionHandler

    Caller->>CB: Dispose()
    CB->>Pool: Rent exception list
    CB->>ECAE: ExecuteAndCollectExceptions(list, cancel lifetime token)
    ECAE->>ECAE: run action
    ECAE-->>COL: exception
    COL->>COL: flatten AggregateException -> collect leaves
    COL->>Handler: raise UnobservedDisposeException for each new leaf
    Handler-->>COL: handler runs
    COL-->>ECAE: return with leaves collected
    CB->>ECAE: ExecuteAndCollectExceptions(list, dispose composite)
    ECAE->>ECAE: run action
    ECAE-->>COL: exception
    COL->>COL: flatten & collect leaves, raise events as needed
    COL-->>ECAE: return
    alt list not empty
        CB->>CB: throw ControllerDisposeAggregateException(list)
    end
    CB->>CB: finally: release children, set state Disposed
    CB->>Pool: Return exception list
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐰 I hopped through disposals, fluff and thread,
Collected leaves of errors that once fled.
I raised a bell for each unobserved plight,
Bundled them snug, then set things right.
Tests in my burrow prove the fix is bright.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.13% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main dispose-safety and exception-aggregation changes in the pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@imurashka imurashka changed the title Add customizable dispose exception handler Move dispose exception event to ControllerBase Mar 5, 2026
@imurashka imurashka changed the title Move dispose exception event to ControllerBase Add customizable dispose exception handler Mar 5, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/ControllersTree/Tests/ControllersWithResultBaseTests.cs (1)

218-228: ⚠️ Potential issue | 🟡 Minor

Assertion message is inconsistent with the expected exception type.

The test now catches AggregateException but the assertion message on line 227 still says "TestControllersException expected". Consider updating the message for clarity:

📝 Suggested fix
-            Assert.IsTrue(exceptionThrown, "TestControllersException expected");
+            Assert.IsTrue(exceptionThrown, "AggregateException expected");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/ControllersTree/Tests/ControllersWithResultBaseTests.cs` around lines 218
- 228, The assertion message is misleading: the catch now handles
AggregateException but Assert.IsTrue(exceptionThrown, "TestControllersException
expected") still references TestControllersException; update the assertion
message in ControllersWithResultBaseTests (the test method containing the catch
for AggregateException) to reflect AggregateException (e.g., "AggregateException
expected") or change the test to assert the specific exception type via
Assert.Throws if you prefer a stronger check; locate the
Assert.IsTrue(exceptionThrown, ...) and replace the message accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/ControllersTree/Core/Controllers/ControllerBase.Exceptions.cs`:
- Around line 16-19: RaiseUnhandledDisposeException currently invokes
UnobservedDisposeException directly which lets a subscriber exception escape and
abort CollectExceptions/DisposeMany; change it to retrieve
UnobservedDisposeException?.GetInvocationList() and invoke each delegate in a
try/catch so subscriber exceptions are caught and do not propagate (optionally
log or aggregate them) ensuring no exception escapes
RaiseUnhandledDisposeException and the exception-collection loop in
CollectExceptions can continue. Include references to
UnobservedDisposeException, RaiseUnhandledDisposeException, CollectExceptions,
and DisposeMany when making the change.

---

Outside diff comments:
In `@src/ControllersTree/Tests/ControllersWithResultBaseTests.cs`:
- Around line 218-228: The assertion message is misleading: the catch now
handles AggregateException but Assert.IsTrue(exceptionThrown,
"TestControllersException expected") still references TestControllersException;
update the assertion message in ControllersWithResultBaseTests (the test method
containing the catch for AggregateException) to reflect AggregateException
(e.g., "AggregateException expected") or change the test to assert the specific
exception type via Assert.Throws if you prefer a stronger check; locate the
Assert.IsTrue(exceptionThrown, ...) and replace the message accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f9adf36d-c9e1-4686-8b14-0beced5df591

📥 Commits

Reviewing files that changed from the base of the PR and between 5c07876 and 43701e6.

📒 Files selected for processing (10)
  • README.md
  • src/ControllersTree/Core/AssemblyInfo.cs
  • src/ControllersTree/Core/Controllers/ControllerBase.Exceptions.cs
  • src/ControllersTree/Core/Controllers/ControllerBase.Exceptions.cs.meta
  • src/ControllersTree/Core/Controllers/ControllerBase.cs
  • src/ControllersTree/Core/Utils/ControllerCompositeDisposable.cs
  • src/ControllersTree/Tests/ControllerBaseExceptionsTests.cs
  • src/ControllersTree/Tests/ControllerBaseExceptionsTests.cs.meta
  • src/ControllersTree/Tests/ControllerCompositeDisposableTests.cs
  • src/ControllersTree/Tests/ControllersWithResultBaseTests.cs

Comment thread src/ControllersTree/Core/Controllers/ControllerBase.Exceptions.cs Outdated
@imurashka imurashka force-pushed the ivan/log-dispose-exception branch from 43701e6 to 489a8a5 Compare March 11, 2026 17:53
@imurashka imurashka changed the title Add customizable dispose exception handler Add dispose exception observability via UnobservedDisposeException Mar 11, 2026
@imurashka

Copy link
Copy Markdown
Contributor Author

@eldarmguseynov @perepechenko hey guys! Any chance you will merge this to main?

@imurashka imurashka force-pushed the ivan/log-dispose-exception branch from 140f184 to 5cc5866 Compare July 6, 2026 14:32
@imurashka imurashka marked this pull request as draft July 6, 2026 14:40
@imurashka imurashka changed the title Add dispose exception observability via UnobservedDisposeException Safer controller dispose: preserve state and aggregate exceptions Jul 6, 2026
@imurashka

Copy link
Copy Markdown
Contributor Author

Changes vs main:

  • Dispose cleanup (child list release + Disposed state) runs in a finally block, so it completes even when a disposable throws.
  • Dispose exceptions are always wrapped in AggregateException, including a single one.
  • DisposeMany returns early on null or empty input.

@imurashka imurashka marked this pull request as ready for review July 6, 2026 14:45
@imurashka

Copy link
Copy Markdown
Contributor Author

@eldarmguseynov @perepechenko hey! cleaned up this PR as we discussed on a call

@imurashka imurashka force-pushed the ivan/log-dispose-exception branch from bae004a to 38bc520 Compare July 6, 2026 15:39
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.

3 participants