Skip to content

[Bug]: ExpireAfter holding items after dispose #1025

Description

@aguahombre

Describe the bug 🐞

If you dispose a observable using ExpireAfter, the items are not available for GC until they expire.
The same occurs using ToObservableChangeSet with expireAfter.
If the items had very long expiry times e.g. days, this could lead to high memory usage.

Step to reproduce

  1. Run this ConsoleApp example and press enter. The observable is disposed, but the items are not GCed until they expire.
    Two alternative variants are commented out, both exhibit the problem.
using DynamicData;
using System.Reactive.Concurrency;
using System.Reactive.Linq;

namespace ExpireAfterLeak;

internal class Program
{
    static void Main(string[] args)
    {
        var stopwatch = new System.Diagnostics.Stopwatch();
        stopwatch.Start();

        Console.WriteLine("Started");
        var subscription = Observable.Interval(TimeSpan.FromMilliseconds(250))
            .Select(x => new Item(x % 100))
            .Do(x => Console.WriteLine($"{stopwatch.ElapsedMilliseconds:D6} Creating Item with value {x.Value}"))
            .Take(5)

            //.Do(x => Console.WriteLine($"{stopwatch.ElapsedMilliseconds:D6} {stopwatch.ElapsedMilliseconds:D6} {string.Join('\n', x)}"), ex => Console.WriteLine($"{stopwatch.ElapsedMilliseconds:D6} {ex}"), () => Console.WriteLine($"{stopwatch.ElapsedMilliseconds:D6} Source Completed"))
            //.ToObservableChangeSet(x => x.Value, expireAfter: _ => TimeSpan.FromSeconds(10), scheduler: NewThreadScheduler.Default)

            //.ToObservableChangeSet(x => x.Value, scheduler: NewThreadScheduler.Default)
            //.Do(x => Console.WriteLine($"{stopwatch.ElapsedMilliseconds:D6} {string.Join('\n', x)}"), ex => Console.WriteLine($"{stopwatch.ElapsedMilliseconds:D6} {ex}"), () => Console.WriteLine($"{stopwatch.ElapsedMilliseconds:D6} Source Completed"))
            //.ExpireAfter(_ => TimeSpan.FromSeconds(10), pollingInterval: TimeSpan.FromSeconds(5), scheduler: NewThreadScheduler.Default)

            .ToObservableChangeSet(x => x.Value, scheduler: NewThreadScheduler.Default)
            .Do(x => Console.WriteLine($"{stopwatch.ElapsedMilliseconds:D6} {string.Join('\n', x)}"), ex => Console.WriteLine($"{stopwatch.ElapsedMilliseconds:D6} {ex}"), () => Console.WriteLine($"{stopwatch.ElapsedMilliseconds:D6} Source Completed"))
            .ExpireAfter(_ => TimeSpan.FromSeconds(10), scheduler: NewThreadScheduler.Default)

            .Subscribe(x => Console.WriteLine($"{stopwatch.ElapsedMilliseconds:D6} {string.Join('\n', x)}"), ex => Console.WriteLine($"{stopwatch.ElapsedMilliseconds:D6} {ex}"), () =>
            {
                Console.WriteLine($"{stopwatch.ElapsedMilliseconds:D6} Subscribe Completed");
                GC.Collect();
            });

        Console.ReadLine();
        Console.WriteLine($"{stopwatch.ElapsedMilliseconds:D6} Dispose");
        subscription.Dispose();

        while (!Console.KeyAvailable)
        {
            GC.Collect();
            GC.WaitForPendingFinalizers();
            GC.Collect();
            Console.WriteLine($"{stopwatch.ElapsedMilliseconds:D6}  GC.Collect");
            Thread.Sleep(1000);
        }
        Console.WriteLine($"{stopwatch.ElapsedMilliseconds:D6} Exit");
    }
}

internal class Item
{
    public long Value { get; }

    public Item(long v) => Value = v;

    ~Item()
    {
        Console.WriteLine($"Item with value {Value} is being finalized.");
    }
}

Results showing items being held until they expire

Started
000296 Creating Item with value 0
000305 Add, Key: 0, Current: ExpireAfterLeak.Item, Previous: <None>
000327 Add, Key: 0, Current: ExpireAfterLeak.Item, Previous: <None>
000548 Creating Item with value 1
000548 Add, Key: 1, Current: ExpireAfterLeak.Item, Previous: <None>
000549 Add, Key: 1, Current: ExpireAfterLeak.Item, Previous: <None>
000799 Creating Item with value 2
000800 Add, Key: 2, Current: ExpireAfterLeak.Item, Previous: <None>
000800 Add, Key: 2, Current: ExpireAfterLeak.Item, Previous: <None>
001049 Creating Item with value 3
001050 Add, Key: 3, Current: ExpireAfterLeak.Item, Previous: <None>
001050 Add, Key: 3, Current: ExpireAfterLeak.Item, Previous: <None>
001301 Creating Item with value 4
001301 Add, Key: 4, Current: ExpireAfterLeak.Item, Previous: <None>
001301 Add, Key: 4, Current: ExpireAfterLeak.Item, Previous: <None>
001302 Source Completed

001619 Dispose
001625  GC.Collect
002637  GC.Collect
003650  GC.Collect
004662  GC.Collect
005673  GC.Collect
006679  GC.Collect
007687  GC.Collect
008702  GC.Collect
009707  GC.Collect
Item with value 2 is being finalized.
Item with value 1 is being finalized.
Item with value 0 is being finalized.
Item with value 4 is being finalized.
Item with value 3 is being finalized.
010720  GC.Collect
011733  GC.Collect
012744  GC.Collect

Reproduction repository

https://github.com/aguahombre/ExpireAfterLeak

Expected behavior

When the observable subscription is disposed, all unexpired items should be released for GC and the timer thread terminated.

Screenshots 🖼️

No response

IDE

No response

Operating system

Windows 10

Version

No response

Device

PC

DynamicData Version

9.4.1

Additional information ℹ️

No response

Activity

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions