microsoft/garnet · error · TsavoriteException
Getting handle in disposed device
Error message
Getting handle in disposed device
What it means
Thrown by AsyncPool<T>.Get (sync acquire) when the pool's disposed flag is already set. The pool is a fixed-capacity handle cache (e.g. of FileStream handles in ManagedLocalStorageDevice); Dispose() sets disposed=true and drains all handles. Get() checks disposed at the top of every loop iteration and throws TsavoriteException rather than handing out a handle to a torn-down device. It is a use-after-free guard: once the device/segment is gone, no handle can be valid.
Source
Thrown at libs/storage/Tsavorite/cs/src/core/Device/AsyncPool.cs:49
public AsyncPool(int size, Func<T> creator)
{
this.size = size;
this.creator = creator;
handleAvailable = new SemaphoreSlim(0);
itemQueue = new ConcurrentQueue<T>();
}
/// <summary>
/// Get item synchronously
/// </summary>
/// <param name="token"></param>
/// <returns></returns>
public T Get(CancellationToken token = default)
{
for (; ; )
{
if (disposed)
throw new TsavoriteException("Getting handle in disposed device");
if (GetOrAdd(itemQueue, out T item))
return item;
handleAvailable.Wait(token);
}
}
/// <summary>
/// Get item asynchronously
/// </summary>
/// <param name="token"></param>
/// <returns></returns>
public async ValueTask<T> GetAsync(CancellationToken token = default)
{
for (; ; )
{
if (disposed)View on GitHub (pinned to 951b0fc683)
Solutions
- Serialize segment removal against in-flight IO: track per-segment pending IO count (numPending-style gate) and only call RemoveSegment/Dispose when it reaches zero, or quiesce the segment first.
- In the consumer, catch TsavoriteException around Get()/GetAsync() and route to the error callback instead of letting it escape (the ReadAsync/WriteAsync Task.Run blocks already have catch scopes at ManagedLocalStorageDevice.cs:197 and :305 — make sure they cover the GetAsync call).
- Guard GetFileSize and other sync callers with a disposed check on the device (_disposed) and a TryGet fast-path before falling back to Get().
- Ensure Dispose() drains numPending to zero (wait for outstanding IO) before disposing the per-segment pools.
Example fix
// before (ManagedLocalStorageDevice.ReadAsync inner Task.Run):
// logReadHandle = await streampool.GetAsync().ConfigureAwait(false);
// after:
// try {
// logReadHandle = await streampool.GetAsync(token).ConfigureAwait(false);
// } catch (TsavoriteException) { // pool disposed mid-read (segment removed)
// Interlocked.Decrement(ref numPending);
// callback(uint.MaxValue, 0, context);
// return;
// } Defensive patterns
Strategy: try-catch
Validate before calling
// Before the sync acquire, fast-fail if the owning device/segment is gone.
if (device.IsDisposed) throw new ObjectDisposedException(nameof(device));
if (!device.logHandles.ContainsKey(segmentId)) return; // segment already removed
// Prefer the non-throwing fast path:
if (!streampool.TryGet(out var handle)) { /* segment draining */ } Type guard
// C# guard: prefer TryGet (returns false on disposed) over Get (throws).
static bool TryAcquire(AsyncPool<Stream> pool, out Stream handle)
=> pool != null && pool.TryGet(out handle); Try / catch
try {
var handle = streampool.Get(token);
// ... use handle ...
} catch (TsavoriteException ex) when (ex.Message.Contains("disposed device")) {
// segment/device was removed concurrently; complete the request with an error
callback(uint.MaxValue, 0, context);
} Prevention
- Never call Get() on a pool whose owning device might be disposing — drain or quiesce the segment first.
- Quiesce outstanding IO (numPending==0) in Dispose() before disposing per-segment pools.
- Prefer TryGet where you can fall back gracefully; reserve Get/GetAsync for paths that already catch TsavoriteException.
- Serialize RemoveSegment against in-flight IO on that segment.
When it happens
Trigger: A caller invokes Get() on a pool that was disposed via ManagedLocalStorageDevice.RemoveSegment (logHandles.TryRemove + pool.Dispose at ManagedLocalStorageDevice.cs:352-356) or device.Dispose() (cs:396-399), while a concurrent ReadAsync/WriteAsync still holds a reference to that pool and falls through to the blocking Get() path. Also reached directly from GetFileSize (cs:380-382) which calls pool.Item1.Get() after a failed TryGet on a pool a racing RemoveSegment is disposing.
Common situations: Segment removal / log compaction racing with in-flight IO on the same segment; calling GetFileSize after Dispose;disposing the device while background writes are still outstanding; shrinking/growing the log concurrently with reads. Most common after enabling segment removal (RemoveSegment/RemoveSegmentAsync) under load.
Related errors
- Getting handle in disposed device
- Throttle count is negative
- Exceeded maximum number of active LightEpoch instances {Acti
- LightEpoch
- TsavoriteLogAllocator does not support VerifyRecordFromDiskC
AI-assisted analysis of microsoft/garnet@951b0fc683 (2026-08-13).
Data as JSON: /api/errors/bd70d5be23d069c5.
Report an issue: GitHub.