{"record":{"id":"bd70d5be23d069c5","repo":"microsoft/garnet","slug":"getting-handle-in-disposed-device-bd70d5","errorCode":null,"errorMessage":"Getting handle in disposed device","messagePattern":"Getting handle in disposed device","errorType":"exception","errorClass":"TsavoriteException","httpStatus":null,"severity":"error","filePath":"libs/storage/Tsavorite/cs/src/core/Device/AsyncPool.cs","lineNumber":49,"sourceCode":"        public AsyncPool(int size, Func<T> creator)\n        {\n            this.size = size;\n            this.creator = creator;\n            handleAvailable = new SemaphoreSlim(0);\n            itemQueue = new ConcurrentQueue<T>();\n        }\n\n        /// <summary>\n        /// Get item synchronously\n        /// </summary>\n        /// <param name=\"token\"></param>\n        /// <returns></returns>\n        public T Get(CancellationToken token = default)\n        {\n            for (; ; )\n            {\n                if (disposed)\n                    throw new TsavoriteException(\"Getting handle in disposed device\");\n\n                if (GetOrAdd(itemQueue, out T item))\n                    return item;\n\n                handleAvailable.Wait(token);\n            }\n        }\n\n        /// <summary>\n        /// Get item asynchronously\n        /// </summary>\n        /// <param name=\"token\"></param>\n        /// <returns></returns>\n        public async ValueTask<T> GetAsync(CancellationToken token = default)\n        {\n            for (; ; )\n            {\n                if (disposed)","sourceCodeStart":31,"sourceCodeEnd":67,"githubUrl":"https://github.com/microsoft/garnet/blob/951b0fc6838721f89d102c2bbe1b914e8d39d700/libs/storage/Tsavorite/cs/src/core/Device/AsyncPool.cs#L31-L67","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before (ManagedLocalStorageDevice.ReadAsync inner Task.Run):\n//     logReadHandle = await streampool.GetAsync().ConfigureAwait(false);\n// after:\n//     try {\n//         logReadHandle = await streampool.GetAsync(token).ConfigureAwait(false);\n//     } catch (TsavoriteException) {            // pool disposed mid-read (segment removed)\n//         Interlocked.Decrement(ref numPending);\n//         callback(uint.MaxValue, 0, context);\n//         return;\n//     }","handlingStrategy":"try-catch","validationCode":"// Before the sync acquire, fast-fail if the owning device/segment is gone.\nif (device.IsDisposed) throw new ObjectDisposedException(nameof(device));\nif (!device.logHandles.ContainsKey(segmentId)) return; // segment already removed\n// Prefer the non-throwing fast path:\nif (!streampool.TryGet(out var handle)) { /* segment draining */ }","typeGuard":"// C# guard: prefer TryGet (returns false on disposed) over Get (throws).\nstatic bool TryAcquire(AsyncPool<Stream> pool, out Stream handle)\n    => pool != null && pool.TryGet(out handle);","tryCatchPattern":"try {\n    var handle = streampool.Get(token);\n    // ... use handle ...\n} catch (TsavoriteException ex) when (ex.Message.Contains(\"disposed device\")) {\n    // segment/device was removed concurrently; complete the request with an error\n    callback(uint.MaxValue, 0, context);\n}","preventionTips":["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."],"tags":["tsavorite","csharp","device","dispose-race","object-pool","concurrency"],"backgroundTag":null,"analyzedSha":"951b0fc6838721f89d102c2bbe1b914e8d39d700","analyzedAt":"2026-08-13T19:01:32.939Z","schemaVersion":2},"datasetVersion":"2026-08-13T19:17:28.613Z"}