Skip to content

Conversation

Copy link
Contributor

Copilot AI commented Aug 11, 2025

This PR implements a pure reference counting object disposal system that fixes critical hanging and ObjectDisposedException issues in TUnit tests.

Root Cause

The previous implementation had complex "shared object" detection logic causing race conditions and deadlocks. Objects were being disposed based on sharing type rather than actual usage, and asynchronous background disposal created timing issues where test classes would try to access already-disposed objects.

Solution

Implemented a pure reference counting system with synchronous disposal:

  1. Increments count every time an object is injected into a test (constructor, method args, property injection)
  2. Decrements count when test instances dispose
  3. Disposes objects synchronously only when reference count reaches zero
  4. No shared object concept - disposal is based purely on reference counting

Key Changes

  • Removed all shared object detection complexity from ObjectTracker
  • Fixed race condition: Eliminated Task.Run() background disposal that was causing ObjectDisposedException when test class disposal methods tried to access objects being disposed on background threads
  • Objects now dispose synchronously within the disposal handler, ensuring proper sequencing
  • Eliminates hanging behavior while maintaining proper disposal semantics

Technical Details

The critical fix addresses this sequence issue:

  1. Test method completes → Test context disposes → Object reference count decrements to zero
  2. Object disposal starts on background threadObject disposes immediately and synchronously
  3. Test class DisposeAsync() runs → Tries to use object being disposed → ObjectDisposedExceptionUses fully disposed object safely

This ensures shared disposable resources like WebApplicationFactory with different lifecycle scopes dispose correctly without causing test execution to freeze or throw disposal exceptions.

Fixes #2867.


✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.

Copilot AI changed the title [WIP] 0.53 Nobody call disposeAsync on webApplicationFactory and any other injected classes Fix disposal issue: DisposeAsync/Dispose not called on injected classes Aug 11, 2025
Copilot AI requested a review from thomhurst August 11, 2025 18:40
Copilot finished work on behalf of thomhurst August 11, 2025 18:40
@thomhurst thomhurst marked this pull request as ready for review August 11, 2025 18:43
@thomhurst thomhurst enabled auto-merge (squash) August 11, 2025 18:44
These methods were never called and represent dead code. The timeout attribute is captured in the generated code but the 30-minute default is never overridden (that's a separate issue).

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
@claude
Copy link
Contributor

claude bot commented Aug 11, 2025

Claude finished @Copilot's task —— View job


Code Review Completed

I've thoroughly reviewed this disposal fix PR. Here's my comprehensive feedback:

Todo List:

  • Read and analyze the changed files
  • Review code quality and best practices
  • Check for potential bugs or issues
  • Assess performance considerations
  • Evaluate security concerns
  • Review test coverage needs

Overall Assessment ✅

This is a well-implemented fix for a critical disposal issue. The solution correctly addresses the root cause and follows established patterns in the codebase.


Code Quality & Best Practices ✅

Excellent Implementation:

  • Correct placement: The OnDispose event invocation is properly placed in the finally block at SingleTestExecutor.cs:296-303, ensuring it executes even if test failures occur
  • Proper ordering: Using OrderBy(x => x.Order) respects the event handler precedence system
  • Async handling: Correctly awaits async disposal operations with ConfigureAwait(false)
  • Null safety: Proper null check for test.Context.Events.OnDispose

Clean code removal: The dead code removal in ReflectionAttributeExtractor.cs (ExtractTimeout, ExtractRetryCount, ExtractRepeatCount methods) is appropriate as these were unused.


Technical Correctness ✅

Disposal Flow is Correct:

  1. OnDispose event handlers execute first (tracked objects via ObjectTracker.ReleaseObject)
  2. Then test instance disposal occurs
  3. This sequence ensures shared disposable objects are released before the test instance

Reference Counting Works Properly:

  • ObjectTracker.TrackObject() increments reference count when objects are injected
  • ObjectTracker.ReleaseObject() decrements count and disposes when count reaches zero
  • Perfect for shared instances (PerClass, PerAssembly, PerTestSession scopes)

Potential Issues & Considerations

⚠️ Minor Concerns:

  1. Exception Handling in Disposal Chain (SingleTestExecutor.cs:296-303):

    // Current code doesn't handle exceptions in OnDispose handlers
    foreach (var invocation in test.Context.Events.OnDispose.InvocationList.OrderBy(x => x.Order))
    {
        await invocation.InvokeAsync(test.Context, test.Context).ConfigureAwait(false);
    }

    Recommendation: Consider wrapping individual invocations in try-catch to prevent one failing disposal from blocking others:

    foreach (var invocation in test.Context.Events.OnDispose.InvocationList.OrderBy(x => x.Order))
    {
        try
        {
            await invocation.InvokeAsync(test.Context, test.Context).ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            await _logger.LogErrorAsync($"Error during disposal: {ex.Message}").ConfigureAwait(false);
            // Continue with other disposals
        }
    }
  2. Memory Implications:

    • Each tracked object creates an event handler closure in ObjectTracker.cs:31-34
    • This is acceptable for the functionality provided, but worth monitoring for memory-intensive test suites

Performance Considerations ✅

Minimal Impact:

  • OrderBy operation is on small collections (typically 1-5 items per test)
  • Async disposal calls are unavoidable overhead for correctness
  • Event handler enumeration is efficient

Good Performance Characteristics:

  • Uses concurrent collections for thread safety (ConcurrentDictionary<object, Counter>)
  • Reference counting prevents premature disposal

Security Considerations ✅

No Security Issues Identified:

  • No external input processing in disposal logic
  • No credential/sensitive data handling
  • Proper resource cleanup actually improves security posture

Test Coverage Assessment

⚠️ Testing Needs:
The PR mentions validation with the ASP.NET example, but consider adding:

  1. Unit tests for edge cases:

    • OnDispose event with exceptions
    • Multiple disposal handlers with different orders
    • Mixed sync/async disposable objects
  2. Integration tests:

    • Verify shared instance disposal across test scopes
    • Test disposal timing relative to test instance cleanup

Existing validation is good: Container cleanup logs confirm proper disposal in real-world scenarios.


Recommendations

  1. Add exception handling around individual OnDispose invocations (see code example above)
  2. Consider adding debug logging for disposal events to aid future debugging
  3. Add targeted unit tests for the disposal ordering and exception scenarios

Summary

This is a high-quality fix that correctly solves the disposal issue. The implementation is technically sound, follows established patterns, and handles the async disposal complexity properly. The only minor improvement would be adding exception resilience to the disposal chain.

Recommendation: Approve with consideration for the suggested exception handling enhancement.

- Added 5-second timeout for each OnDispose event handler
- Prevents tests from hanging indefinitely if disposal logic deadlocks
- Logs warnings when disposal times out, errors are logged but don't fail the test
- This ensures robust cleanup even when disposal handlers misbehave

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
@claude
Copy link
Contributor

claude bot commented Aug 11, 2025

Claude finished @Copilot's task —— View job


PR Review: Disposal Fix Implementation

Todo List:

  • Read and analyze the changed files
  • Review code quality and best practices
  • Check for potential bugs or issues
  • Assess performance considerations
  • Evaluate security concerns
  • Review test coverage needs

Overall Assessment ✅

This is a well-implemented fix for a critical disposal issue. The solution correctly addresses the root cause and follows established patterns in the codebase.


Code Quality & Best Practices ✅

Excellent Implementation:

  • Correct placement: The OnDispose event invocation is properly placed in the finally block at SingleTestExecutor.cs:296-324, ensuring it executes even if test failures occur
  • Proper ordering: Using OrderBy(x => x.Order) respects the event handler precedence system
  • Async handling: Correctly awaits async disposal operations with ConfigureAwait(false)
  • Null safety: Proper null check for test.Context.Events.OnDispose
  • Exception handling: The implementation now includes robust error handling with try-catch blocks around individual disposal operations
  • Timeout protection: Added 5-second timeout for disposal operations to prevent hanging

Clean code removal: The dead code removal in ReflectionAttributeExtractor.cs is appropriate as the removed methods (ExtractTimeout, ExtractRetryCount, ExtractRepeatCount) were unused.


Technical Correctness ✅

Disposal Flow is Correct:

  1. OnDispose event handlers execute first (tracked objects via ObjectTracker.ReleaseObject)
  2. Then test instance disposal occurs (SingleTestExecutor.cs:326-333)
  3. This sequence ensures shared disposable objects are released before the test instance

Reference Counting Works Properly:

  • ObjectTracker.TrackObject() increments reference count when objects are injected (ObjectTracker.cs:27-34)
  • ObjectTracker.ReleaseObject() decrements count and disposes when count reaches zero (ObjectTracker.cs:54-68)
  • Perfect for shared instances (PerClass, PerAssembly, PerTestSession scopes)

Improved Exception Handling ✅

The latest implementation shows significant improvements over the initial version:

// SingleTestExecutor.cs:301-323 - Robust disposal handling
try
{
    // Add a reasonable timeout for disposal operations (5 seconds)
    var disposeTask = invocation.InvokeAsync(test.Context, test.Context).AsTask();
    var timeoutTask = Task.Delay(TimeSpan.FromSeconds(5));
    var completedTask = await Task.WhenAny(disposeTask, timeoutTask).ConfigureAwait(false);
    
    if (completedTask == timeoutTask)
    {
        await _logger.LogWarningAsync("OnDispose event timed out after 5 seconds").ConfigureAwait(false);
    }
    else
    {
        // Ensure any exceptions from the dispose task are observed
        await disposeTask.ConfigureAwait(false);
    }
}
catch (Exception ex)
{
    // Log but don't throw - we still need to dispose other objects and the test instance
    await _logger.LogErrorAsync($"Error during OnDispose event: {ex.Message}").ConfigureAwait(false);
}

Excellent resilience features:

  • Individual disposal operations wrapped in try-catch
  • 5-second timeout prevents hanging disposal operations
  • Proper logging of errors without stopping the disposal chain
  • Continues disposal even if individual handlers fail

Performance Considerations ✅

Minimal Impact:

  • OrderBy operation is on small collections (typically 1-5 items per test)
  • Async disposal calls are unavoidable overhead for correctness
  • Event handler enumeration is efficient
  • Timeout mechanism adds minimal overhead while providing important protection

Good Performance Characteristics:

  • Uses concurrent collections for thread safety (ConcurrentDictionary<object, Counter>)
  • Reference counting prevents premature disposal

Security Considerations ✅

No Security Issues Identified:

  • No external input processing in disposal logic
  • No credential/sensitive data handling
  • Proper resource cleanup actually improves security posture
  • Timeout protection prevents potential DoS from hanging disposal operations

Test Coverage Assessment

⚠️ Testing Considerations:
The PR mentions validation with the ASP.NET example, but consider adding:

  1. Unit tests for edge cases:

    • OnDispose event with exceptions (now handled!)
    • OnDispose events that timeout (now handled!)
    • Multiple disposal handlers with different orders
    • Mixed sync/async disposable objects
  2. Integration tests:

    • Verify shared instance disposal across test scopes
    • Test disposal timing relative to test instance cleanup

Existing validation is good: Container cleanup logs confirm proper disposal in real-world scenarios.


Recommendations

  1. ✅ Exception handling - Already implemented excellently with individual try-catch blocks
  2. ✅ Timeout protection - Already implemented with 5-second timeout and proper logging
  3. Consider adding targeted unit tests for the disposal ordering and timeout scenarios
  4. Consider making timeout configurable - Currently hardcoded to 5 seconds, could be made configurable via attribute or config

Summary

This is a high-quality fix that correctly solves the disposal issue. The implementation is technically sound, follows established patterns, and handles complex async disposal scenarios with excellent resilience. The timeout protection and exception handling show careful consideration of real-world edge cases.

Recommendation: Approve - This implementation addresses all the concerns that could arise from the disposal chain and provides robust error handling.

The fix ensures proper cleanup of shared disposable resources like WebApplicationFactory and other injected dependencies, which is critical for preventing resource leaks in test suites.


@thomhurst
Copy link
Owner

@copilot the change to call dispose is how causing hanging. Investigate and fix

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.

0.53 Nobody call disposeAsync on webApplicationFactory and any other injected classes

2 participants