Files
pgsql-jellyfin/docs/LIBRARYMONITOR_DISPOSAL_FIX.md
wjones 7eb2b445cb Postgres perf, path migration, and shutdown race fixes
- Major query performance boost: replaced correlated subqueries with DistinctBy() in BaseItemRepository, added episode deduplication index, and increased default command timeout to 120s.
- Fixed LibraryMonitor shutdown race: added disposal checks and exception handling in FileRefresher to prevent ObjectDisposedException.
- Added PowerShell and SQL utilities for fixing Linux paths in config files and database; new scripts for WebDir and path migration.
- Auto-fix for WebDir in startup.json on load; new helper scripts and docs.
- Added automated weekly DB performance monitoring scripts and reporting.
- Expanded documentation and README for all fixes, scripts, and migration steps.
- Updated SQL scripts for index creation and diagnostics; improved output handling.
- Updated db-config defaults and added new diagnostic/report files.
2026-03-05 08:56:42 -05:00

6.9 KiB

ObjectDisposedException in LibraryMonitor Fix

Issue

Error: System.ObjectDisposedException: Cannot access a disposed object. Object name: 'IServiceProvider'

Location: Emby.Server.Implementations.IO.LibraryMonitorFileRefresherOnTimerCallback

Stack Trace:

at Microsoft.Extensions.DependencyInjection.ServiceLookup.ThrowHelper.ThrowObjectDisposedException()
at Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngineScope.GetService(Type serviceType)
at Microsoft.EntityFrameworkCore.Internal.DbContextPool
at Jellyfin.Server.Implementations.Item.BaseItemRepository.GetItemListAsync
at Emby.Server.Implementations.Library.LibraryManager.FindByPath
at Emby.Server.Implementations.IO.FileRefresher.GetAffectedBaseItem
at Emby.Server.Implementations.IO.FileRefresher.ProcessPathChanges

Root Cause

The FileRefresher class uses a timer to batch file system change notifications. During application shutdown:

  1. The IServiceProvider and DbContextFactory are disposed
  2. ⏱️ A FileRefresher timer callback fires after disposal has started
  3. The callback tries to access the database through LibraryManager.FindByPath
  4. The disposed ServiceProvider throws ObjectDisposedException

Race Condition

Application Shutdown Timeline:
┌─────────────────────────────────────────────────────┐
│ T0: ServiceProvider.Dispose() called                │
│ T1: LibraryMonitor.Dispose() called                 │
│ T2: FileRefresher.Dispose() called                  │
│ T3: Timer.Dispose() called                          │
│ T4: [RACE] Timer callback fires before cancellation │ ❌
│ T5: Callback tries to access DbContext              │ ❌
│ T6: ObjectDisposedException thrown                  │ ❌
└─────────────────────────────────────────────────────┘

The timer callback can fire in the window between FileRefresher.Dispose() being called and the timer actually being disposed/cancelled.


Solution

Fix 1: Early Disposal Check in Timer Callback

Added disposal check at the start of OnTimerCallback to exit immediately if disposed:

private void OnTimerCallback(object? state)
{
    // Check if disposed before processing
    if (_disposed)
    {
        return;
    }

    // ... rest of the method
}

Fix 2: Double-Check After Timer Disposal

Added a second check after the timer is disposed but before accessing services:

DisposeTimer();
Completed?.Invoke(this, EventArgs.Empty);

// Double-check disposal after timer is disposed
if (_disposed)
{
    return;
}

try
{
    ProcessPathChanges(paths);
}

Fix 3: Catch ObjectDisposedException in ProcessPathChanges

Added explicit handling for ObjectDisposedException when accessing services:

private void ProcessPathChanges(List<string> paths)
{
    if (_disposed)
    {
        return;
    }

    IEnumerable<BaseItem> itemsToRefresh;
    
    try
    {
        itemsToRefresh = paths
            .Distinct()
            .Select(GetAffectedBaseItem) // ← May throw ObjectDisposedException
            .Where(item => item is not null)
            .DistinctBy(x => x!.Id)!;
    }
    catch (ObjectDisposedException)
    {
        // Service provider disposed during shutdown, skip processing
        _logger.LogDebug("Service provider disposed during path change processing, skipping refresh");
        return;
    }

    // ... process items
}

Technical Details

Why This Happens

  1. Pooled DbContext Factory: The application uses AddPooledDbContextFactory<JellyfinDbContext>() which creates contexts from a pooled factory
  2. Service Lifetime: The factory depends on the IServiceProvider which is disposed during shutdown
  3. Async Timer Callbacks: Timer callbacks execute on thread pool threads and may outlive the disposing object
  4. Long-Lived Service: LibraryMonitor is a singleton that monitors file system changes throughout the application lifetime

Service Dependency Chain

FileRefresher (IDisposable)
  ↓ uses
LibraryManager (Singleton)
  ↓ uses
BaseItemRepository (Transient)
  ↓ uses
IDbContextFactory<JellyfinDbContext> (Singleton)
  ↓ creates
JellyfinDbContext (Pooled)
  ↓ created from
IServiceProvider (Disposed during shutdown)

Files Modified

Emby.Server.Implementations\IO\FileRefresher.cs

Changes:

  1. Added disposal check at start of OnTimerCallback (line ~114)
  2. Added disposal check before ProcessPathChanges (line ~128)
  3. Added disposal check in ProcessPathChanges (line ~140)
  4. Added try-catch for ObjectDisposedException in ProcessPathChanges (line ~145-153)

Impact:

  • Prevents timer callbacks from executing after disposal
  • Gracefully handles race conditions during shutdown
  • Reduces error noise in logs during application shutdown
  • No impact on normal operation (disposal only happens during shutdown)

Testing

Reproduction Steps

  1. Start Jellyfin
  2. Modify files in a monitored library folder
  3. Immediately shut down Jellyfin before the file refresh timer fires
  4. Expected Before Fix: ObjectDisposedException in logs
  5. Expected After Fix: Clean shutdown, no exception

Verification

  • Build successful
  • No breaking changes
  • Disposal checks are lightweight (simple boolean check)
  • Exception handling only activates during shutdown race condition

  • Issue Type: Race condition during application shutdown
  • Severity: Low (cosmetic error during shutdown, no impact on operation)
  • Frequency: Intermittent (depends on timing of file changes vs shutdown)
  • Impact: Error logs during shutdown, no functional impact

Prevention Strategy

Best Practices Implemented

  1. Early Exit Pattern: Check disposal state as early as possible
  2. Double-Check Locking: Verify disposal state after lock release
  3. Defensive Error Handling: Catch and log disposal exceptions gracefully
  4. Lightweight Guards: Use boolean flags instead of expensive checks

Alternative Solutions Considered

  1. CancellationToken in Timer: Timers don't support cancellation tokens natively
  2. Await Timer Completion: Would delay shutdown unnecessarily
  3. Disposal Flags + Try-Catch: Chosen for simplicity and effectiveness

Summary

This fix addresses a race condition during application shutdown where file system monitor timer callbacks can execute after the service provider has been disposed. The solution uses multiple layers of disposal checks and defensive exception handling to ensure clean shutdowns without error noise.

Status: Fixed
Risk: Low (shutdown-only scenario)
Testing: Build verified successful
Documentation: Complete