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

  1. 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.
  2. 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).
  3. Guard GetFileSize and other sync callers with a disposed check on the device (_disposed) and a TryGet fast-path before falling back to Get().
  4. 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

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


AI-assisted analysis of microsoft/garnet@951b0fc683 (2026-08-13). Data as JSON: /api/errors/bd70d5be23d069c5. Report an issue: GitHub.