Skip to content

Commit 5821262

Browse files
marius-bughiuclaude
andcommitted
fix(pooled): throw ObjectDisposedException from Count/Keys/Values after Dispose
PooledCelerityDictionary and PooledCeleritySet document that "after Dispose every member throws ObjectDisposedException", but the read accessors Count (both types) and Keys / Values (dictionary) skipped ThrowIfDisposed(). Since Dispose zeroes _count and returns the backing arrays to ArrayPool.Shared, a post-dispose read returned a silent, misleading result (0, or an empty view) over buffers the pool may have re-handed out. Gate all three on ThrowIfDisposed() so the code matches the documented contract, and extend the UseAfterDispose regression tests to cover them. Closes #296 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b48cd75 commit 5821262

5 files changed

Lines changed: 54 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ All notable changes to Celerity are documented here. This project follows [Keep
88

99
- **`Trie<TValue>`** in `Celerity.Collections` — an ordered prefix tree mapping `string` keys to values, filling a BCL gap (.NET ships no trie). `GetByPrefix` lists every entry whose key starts with a prefix in `O(prefix + matches)` and in ascending key order, and `TryGetLongestPrefix` finds the longest stored key that is a prefix of a query in `O(query)` — the autocomplete, longest-prefix-routing, and ordered-iteration workloads a `Dictionary<string, TValue>` can only answer with an `O(n)` scan plus a `StartsWith` per key. Exact `Add` / `TryGetValue` favour a `Dictionary`, so the trie earns its place on the prefix operations. Implements `IReadOnlyDictionary<string, TValue?>`; not thread-safe. Closes [#285](https://github.com/marius-bughiu/Celerity/issues/285).
1010

11+
### Fixed
12+
13+
- `PooledCelerityDictionary` and `PooledCeleritySet` now throw `ObjectDisposedException` from their read accessors (`Count`, and the dictionary's `Keys` / `Values`) after `Dispose`, matching the documented "every member throws after disposal" contract. Previously these returned a silent, misleading result over arrays already returned to `ArrayPool.Shared`. Closes [#296](https://github.com/marius-bughiu/Celerity/issues/296).
14+
1115
## [2.3.0] - 2026-07-19
1216

1317
### Added

src/Celerity.Tests/Collections/PooledCelerityDictionaryTests.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,15 @@ public void UseAfterDispose_ShouldThrowObjectDisposedException()
274274
Assert.Throws<ObjectDisposedException>(() => map.TryAdd(2, 2));
275275
Assert.Throws<ObjectDisposedException>(() => map.Clear());
276276
Assert.Throws<ObjectDisposedException>(() => map.GetEnumerator());
277+
Assert.Throws<ObjectDisposedException>(() => map.EnsureCapacity(64));
278+
Assert.Throws<ObjectDisposedException>(() => map.TrimExcess());
279+
// Read accessors must also honour the disposed contract: Dispose returns
280+
// the backing arrays to the pool, so a silent Count / Keys / Values here
281+
// would report over — or enumerate — buffers the pool may have re-handed
282+
// out. Regression for #296.
283+
Assert.Throws<ObjectDisposedException>(() => _ = map.Count);
284+
Assert.Throws<ObjectDisposedException>(() => _ = map.Keys);
285+
Assert.Throws<ObjectDisposedException>(() => _ = map.Values);
277286
}
278287

279288
[Fact]

src/Celerity.Tests/Collections/PooledCeleritySetTests.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,11 @@ public void UseAfterDispose_ShouldThrowObjectDisposedException()
233233
Assert.Throws<ObjectDisposedException>(() => set.Overlaps(new[] { 3 }));
234234
Assert.Throws<ObjectDisposedException>(() => set.SetEquals(new[] { 3 }));
235235
Assert.Throws<ObjectDisposedException>(() => set.CopyTo(new int[4], 0));
236+
// Read accessor must also honour the disposed contract: Dispose zeroes
237+
// _count and returns the backing array to the pool, so a silent Count
238+
// here would report 0 over a buffer the pool may have re-handed out.
239+
// Regression for #296.
240+
Assert.Throws<ObjectDisposedException>(() => _ = set.Count);
236241
}
237242

238243
[Fact]

src/Celerity/Collections/PooledCelerityDictionary.cs

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,15 @@ private static int InitialCapacityForSource(
202202
/// <summary>
203203
/// Gets the number of key/value pairs contained in the dictionary.
204204
/// </summary>
205-
public int Count => _count;
205+
/// <exception cref="ObjectDisposedException">The dictionary has been disposed.</exception>
206+
public int Count
207+
{
208+
get
209+
{
210+
ThrowIfDisposed();
211+
return _count;
212+
}
213+
}
206214

207215
/// <summary>
208216
/// Gets or sets the value associated with the specified key.
@@ -619,13 +627,29 @@ public Enumerator GetEnumerator()
619627
/// Gets an enumerable view over the keys in the dictionary. The view is a
620628
/// lightweight struct and iterating it does not allocate.
621629
/// </summary>
622-
public KeyCollection Keys => new KeyCollection(this);
630+
/// <exception cref="ObjectDisposedException">The dictionary has been disposed.</exception>
631+
public KeyCollection Keys
632+
{
633+
get
634+
{
635+
ThrowIfDisposed();
636+
return new KeyCollection(this);
637+
}
638+
}
623639

624640
/// <summary>
625641
/// Gets an enumerable view over the values in the dictionary. The view is a
626642
/// lightweight struct and iterating it does not allocate.
627643
/// </summary>
628-
public ValueCollection Values => new ValueCollection(this);
644+
/// <exception cref="ObjectDisposedException">The dictionary has been disposed.</exception>
645+
public ValueCollection Values
646+
{
647+
get
648+
{
649+
ThrowIfDisposed();
650+
return new ValueCollection(this);
651+
}
652+
}
629653

630654
/// <summary>
631655
/// A struct enumerator over a

src/Celerity/Collections/PooledCeleritySet.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,15 @@ private static int InitialCapacityForSource(IEnumerable<T> source, int capacity,
192192
/// <summary>
193193
/// Gets the number of elements contained in the set.
194194
/// </summary>
195-
public int Count => _count;
195+
/// <exception cref="ObjectDisposedException">The set has been disposed.</exception>
196+
public int Count
197+
{
198+
get
199+
{
200+
ThrowIfDisposed();
201+
return _count;
202+
}
203+
}
196204

197205
/// <summary>
198206
/// Adds the specified element to the set.

0 commit comments

Comments
 (0)