Skip to content

Commit 8b648de

Browse files
authored
Merge pull request #13 from imurashka/ivan/composite-disposable-fix
ControllerCompositeDisposable: safer post-dispose behavior
2 parents 18749e3 + f006ea4 commit 8b648de

File tree

4 files changed

+321
-13
lines changed

4 files changed

+321
-13
lines changed

src/ControllersTree/Core/Utils/ControllerCompositeDisposable.cs

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,22 @@ namespace Playtika.Controllers
1111
public class ControllerCompositeDisposable : IDisposable
1212
{
1313
private readonly List<IDisposable> _disposables = ListPool<IDisposable>.Get();
14+
private bool _disposed = false;
1415

1516
/// <summary>
1617
/// Adds a disposable object to the internal list of disposables.
1718
/// </summary>
1819
/// <param name="disposable">The disposable object to add to the list.</param>
1920
public void Add(IDisposable disposable)
2021
{
21-
_disposables.Add(disposable);
22+
if (_disposed)
23+
{
24+
disposable?.Dispose();
25+
}
26+
else if (disposable != null)
27+
{
28+
_disposables.Add(disposable);
29+
}
2230
}
2331

2432
/// <summary>
@@ -27,33 +35,63 @@ public void Add(IDisposable disposable)
2735
/// <param name="collection">The collection of disposable objects to add to the list.</param>
2836
public void AddRange(IEnumerable<IDisposable> collection)
2937
{
30-
_disposables.AddRange(collection);
38+
if (collection == null)
39+
{
40+
return;
41+
}
42+
43+
if (_disposed)
44+
{
45+
using var pooledObject = ListPool<IDisposable>.Get(out var disposablesList);
46+
disposablesList.AddRange(collection.Where(c => c != null));
47+
DisposeMany(disposablesList);
48+
}
49+
else
50+
{
51+
_disposables.AddRange(collection);
52+
}
3153
}
3254

3355
public void Dispose()
3456
{
35-
using var pooledObject = ListPool<Exception>.Get(out var exceptionList);
57+
if (_disposed)
58+
{
59+
return;
60+
}
61+
62+
_disposed = true;
63+
64+
try
65+
{
66+
DisposeMany(_disposables);
67+
}
68+
finally
69+
{
70+
ListPool<IDisposable>.Release(_disposables);
71+
}
72+
}
3673

37-
foreach (var disposable in _disposables)
74+
private static void DisposeMany(IEnumerable<IDisposable> disposables)
75+
{
76+
using var pooledObject = ListPool<Exception>.Get(out var exceptionList);
77+
foreach (var disposable in disposables)
3878
{
3979
try
4080
{
41-
disposable.Dispose();
81+
disposable?.Dispose();
4282
}
4383
catch (Exception e)
4484
{
4585
exceptionList.Add(e);
4686
}
4787
}
4888

49-
_disposables.Clear();
50-
51-
ListPool<IDisposable>.Release(_disposables);
52-
53-
if (exceptionList.Any())
89+
switch (exceptionList.Count)
5490
{
55-
throw new AggregateException(exceptionList);
91+
case 0: return;
92+
case 1: throw exceptionList[0];
93+
default: throw new AggregateException(exceptionList.ToList());
5694
}
5795
}
5896
}
59-
}
97+
}
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
using System;
2+
using NUnit.Framework;
3+
using Playtika.Controllers;
4+
5+
namespace UnitTests.Controllers
6+
{
7+
[TestFixture]
8+
public class ControllerCompositeDisposableTests
9+
{
10+
[Test]
11+
public void ControllerCompositeDisposable_SingleDisposable_Disposes()
12+
{
13+
// Arrange
14+
var composite = new ControllerCompositeDisposable();
15+
var disposable = new TestDisposable();
16+
17+
composite.Add(disposable);
18+
19+
// Act
20+
composite.Dispose();
21+
22+
// Assert
23+
Assert.AreEqual(1, disposable.DisposeCallCount, "Disposable should be disposed exactly once");
24+
}
25+
26+
[Test]
27+
public void ControllerCompositeDisposable_DisposeEmpty_DoesNotThrow()
28+
{
29+
// Arrange
30+
var composite = new ControllerCompositeDisposable();
31+
32+
// Act / Assert
33+
Assert.DoesNotThrow(
34+
() =>
35+
{
36+
composite.Dispose();
37+
});
38+
}
39+
40+
[Test]
41+
public void ControllerCompositeDisposable_DisposeTwice_IsIdempotent()
42+
{
43+
// Arrange
44+
var composite = new ControllerCompositeDisposable();
45+
var disposable = new TestDisposable();
46+
composite.Add(disposable);
47+
48+
// Act
49+
composite.Dispose();
50+
composite.Dispose();
51+
52+
// Assert
53+
Assert.AreEqual(1, disposable.DisposeCallCount, "Disposable should still be disposed exactly once");
54+
}
55+
56+
[Test]
57+
public void ControllerCompositeDisposable_AddAfterDisposed_DisposesImmediately()
58+
{
59+
// Arrange
60+
var composite = new ControllerCompositeDisposable();
61+
var disposable = new TestDisposable();
62+
63+
composite.Dispose();
64+
65+
// Act
66+
composite.Add(disposable);
67+
68+
// Assert
69+
Assert.AreEqual(1, disposable.DisposeCallCount, "Disposable added after composite dispose should be disposed immediately");
70+
}
71+
72+
[Test]
73+
public void ControllerCompositeDisposable_AddNull_IgnoredAndOtherDisposablesWork()
74+
{
75+
// Arrange
76+
var composite = new ControllerCompositeDisposable();
77+
var disposable = new TestDisposable();
78+
79+
composite.Add(null);
80+
composite.Add(disposable);
81+
composite.Add(null);
82+
83+
// Act
84+
composite.Dispose();
85+
86+
// Assert
87+
Assert.AreEqual(1, disposable.DisposeCallCount, "Non-null disposable should be disposed exactly once");
88+
}
89+
90+
[Test]
91+
public void ControllerCompositeDisposable_AddRangeBeforeDispose_DisposesAll()
92+
{
93+
// Arrange
94+
var composite = new ControllerCompositeDisposable();
95+
var d1 = new TestDisposable();
96+
var d2 = new TestDisposable();
97+
var collection = new[] { d1, d2 };
98+
99+
composite.AddRange(collection);
100+
101+
// Act
102+
composite.Dispose();
103+
104+
// Assert
105+
Assert.AreEqual(1, d1.DisposeCallCount, "First disposable should be disposed exactly once");
106+
Assert.AreEqual(1, d2.DisposeCallCount, "Second disposable should be disposed exactly once");
107+
}
108+
109+
[Test]
110+
public void ControllerCompositeDisposable_AddRangeNullCollection_DoesNotThrow()
111+
{
112+
// Arrange
113+
var composite = new ControllerCompositeDisposable();
114+
115+
// Act / Assert
116+
Assert.DoesNotThrow(
117+
() =>
118+
{
119+
composite.AddRange(null);
120+
composite.Dispose();
121+
});
122+
}
123+
124+
[Test]
125+
public void ControllerCompositeDisposable_AddRangeWithNullItems_DisposesNonNullOnly()
126+
{
127+
// Arrange
128+
var composite = new ControllerCompositeDisposable();
129+
var d1 = new TestDisposable();
130+
var d3 = new TestDisposable();
131+
var collection = new[] { d1, null, d3 };
132+
133+
composite.AddRange(collection);
134+
135+
// Act
136+
composite.Dispose();
137+
138+
// Assert
139+
Assert.AreEqual(1, d1.DisposeCallCount, "First non-null disposable should be disposed exactly once");
140+
Assert.AreEqual(1, d3.DisposeCallCount, "Second non-null disposable should be disposed exactly once");
141+
}
142+
143+
[Test]
144+
public void ControllerCompositeDisposable_AddRangeAfterDisposed_DisposesAllImmediately()
145+
{
146+
// Arrange
147+
var composite = new ControllerCompositeDisposable();
148+
var d1 = new TestDisposable();
149+
var d2 = new TestDisposable();
150+
var collection = new[] { d1, d2 };
151+
152+
composite.Dispose();
153+
154+
// Act
155+
composite.AddRange(collection);
156+
157+
// Assert
158+
Assert.AreEqual(1, d1.DisposeCallCount, "First disposable should be disposed immediately");
159+
Assert.AreEqual(1, d2.DisposeCallCount, "Second disposable should be disposed immediately");
160+
}
161+
162+
[Test]
163+
public void ControllerCompositeDisposable_OneDisposableThrows_ThrowsSingleExceptionAndDisposesOthers()
164+
{
165+
// Arrange
166+
var composite = new ControllerCompositeDisposable();
167+
var throwingDisposable = new TestDisposable { ThrowOnDispose = true };
168+
var normalDisposable = new TestDisposable();
169+
170+
composite.Add(throwingDisposable);
171+
composite.Add(normalDisposable);
172+
173+
// Act
174+
var exception = Assert.Throws<TestControllersException>(
175+
() => composite.Dispose(),
176+
"Expected TestControllersException from throwing disposable");
177+
178+
// Assert
179+
Assert.IsNotNull(exception, "Exception should not be null");
180+
Assert.AreEqual(1, throwingDisposable.DisposeCallCount, "Throwing disposable should be disposed exactly once");
181+
Assert.AreEqual(1, normalDisposable.DisposeCallCount, "Normal disposable should still be disposed exactly once");
182+
}
183+
184+
[Test]
185+
public void ControllerCompositeDisposable_MultipleDisposablesThrow_ThrowsAggregateExceptionWithAllInner()
186+
{
187+
// Arrange
188+
var composite = new ControllerCompositeDisposable();
189+
var d1 = new TestDisposable("d1") { ThrowOnDispose = true };
190+
var d2 = new TestDisposable("d2") { ThrowOnDispose = true };
191+
var d3 = new TestDisposable("d3");
192+
193+
composite.Add(d1);
194+
composite.Add(d2);
195+
composite.Add(d3);
196+
197+
// Act
198+
var aggregate = Assert.Throws<AggregateException>(
199+
() => composite.Dispose(),
200+
"Expected AggregateException when multiple disposables throw");
201+
202+
// Assert
203+
Assert.IsNotNull(aggregate, "AggregateException should not be null");
204+
Assert.AreEqual(2, aggregate.InnerExceptions.Count, "AggregateException should contain all throwing disposables");
205+
foreach (var inner in aggregate.InnerExceptions)
206+
{
207+
Assert.IsInstanceOf<TestControllersException>(inner, "Inner exception should be TestControllersException");
208+
}
209+
210+
Assert.AreEqual(1, d1.DisposeCallCount, "First throwing disposable should be disposed exactly once");
211+
Assert.AreEqual(1, d2.DisposeCallCount, "Second throwing disposable should be disposed exactly once");
212+
Assert.AreEqual(1, d3.DisposeCallCount, "Non-throwing disposable should be disposed exactly once");
213+
}
214+
215+
[Test]
216+
public void ControllerCompositeDisposable_AddRangeAfterDisposed_MultipleDisposablesThrow_ThrowsAggregateException()
217+
{
218+
// Arrange
219+
var composite = new ControllerCompositeDisposable();
220+
var d1 = new TestDisposable("d1") { ThrowOnDispose = true };
221+
var d2 = new TestDisposable("d2") { ThrowOnDispose = true };
222+
var d3 = new TestDisposable("d3"); // does not throw
223+
var collection = new[] { d1, d2, d3 };
224+
225+
composite.Dispose();
226+
227+
// Act
228+
var aggregate = Assert.Throws<AggregateException>(
229+
() => composite.AddRange(collection),
230+
"Expected AggregateException when multiple disposables added after dispose throw");
231+
232+
// Assert
233+
Assert.IsNotNull(aggregate, "AggregateException should not be null");
234+
Assert.AreEqual(2, aggregate.InnerExceptions.Count, "AggregateException should contain all throwing disposables");
235+
foreach (var inner in aggregate.InnerExceptions)
236+
{
237+
Assert.IsInstanceOf<TestControllersException>(inner, "Inner exception should be TestControllersException");
238+
}
239+
240+
Assert.AreEqual(1, d1.DisposeCallCount, "First throwing disposable should be disposed exactly once");
241+
Assert.AreEqual(1, d2.DisposeCallCount, "Second throwing disposable should be disposed exactly once");
242+
Assert.AreEqual(1, d3.DisposeCallCount, "Non-throwing disposable should be disposed exactly once");
243+
}
244+
245+
private sealed class TestDisposable : IDisposable
246+
{
247+
public int DisposeCallCount { get; private set; }
248+
public bool ThrowOnDispose { get; set; }
249+
public string Id { get; }
250+
251+
public TestDisposable(string id = null)
252+
{
253+
Id = id;
254+
}
255+
256+
public void Dispose()
257+
{
258+
DisposeCallCount++;
259+
260+
if (ThrowOnDispose)
261+
{
262+
throw new TestControllersException($"Dispose:{Id ?? "unknown"}");
263+
}
264+
}
265+
}
266+
}
267+
}

src/ControllersTree/Tests/ControllerCompositeDisposableTests.cs.meta

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/ControllersTree/Tests/ControllersWithResultBaseTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ public async Task ControllerWithResultBase_Exception_Dispose()
215215
.LaunchAsync<ActionModelTestControllerWithResult_CompleteOnStart, TestControllerArgs, TestEmptyControllerResult>(
216216
args, _controllerFactory, CancellationToken);
217217
}
218-
catch (AggregateException)
218+
catch (TestControllersException)
219219
{
220220
exceptionThrown = true;
221221
}

0 commit comments

Comments
 (0)