Compare commits

...

5 Commits

Author SHA1 Message Date
Mike Griese
e47c5e1467 Reapply "this doesn't seem relevant"
This reverts commit 9435d79e8e.
2026-06-24 16:17:12 -05:00
Mike Griese
9435d79e8e Revert "this doesn't seem relevant"
This reverts commit a391f49d70.
2026-06-24 15:39:12 -05:00
Mike Griese
a391f49d70 this doesn't seem relevant 2026-06-24 15:39:07 -05:00
Mike Griese
89a481399c this seems more correct 2026-06-24 15:38:47 -05:00
Mike Griese
f28d9b55af This is definitely a memory leak 2026-06-24 15:38:09 -05:00
3 changed files with 32 additions and 26 deletions

View File

@@ -324,12 +324,24 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
switch (propertyName)
{
case nameof(Command):
Command.PropertyChanged -= Command_PropertyChanged;
var oldCommand = Command;
oldCommand.PropertyChanged -= Command_PropertyChanged;
var command = model.Command;
Command = new(command, PageContext);
Command.InitializeProperties();
Command.PropertyChanged += Command_PropertyChanged;
// The previous CommandViewModel subscribed to its extension
// model's PropChanged event (a WinRT/COM event). Replacing the
// reference is not enough: that subscription roots the old
// CommandViewModel - and the COM object, icon view models, and
// ComWrappers behind it - keeping them all alive forever. For
// fallback items the Command is swapped on every keystroke, so
// failing to clean it up leaks an entire view-model + COM tree
// per character typed. SafeCleanup() unsubscribes the COM
// PropChanged handler so the old graph can be collected.
oldCommand.SafeCleanup();
// Extensions based on Command Palette SDK < 0.3 CommandItem class won't notify when Title changes because Command
// or Command.Name change. This is a workaround to ensure that the Title is always up-to-date for extensions with old SDK.
_itemTitle = model.Title;

View File

@@ -10,16 +10,18 @@ namespace Microsoft.CmdPal.UI.ViewModels;
/// <summary>
/// An elastic pool of dedicated background threads for running blocking work
/// off the ThreadPool. Starts with <c>minThreads</c> always-alive threads and
/// expands up to <c>maxThreads</c> on demand. Threads above the minimum exit
/// automatically after <c>idleTimeout</c> with no work. Items are processed
/// FIFO; cancelled items are skipped at dequeue time.
/// expands up to <c>maxThreads</c> on demand. Once created, threads are kept
/// alive for the lifetime of the pool and reused — they are never shrunk back
/// below the high-water mark. Terminating a managed thread leaks per-thread
/// native CLR/CRT bookkeeping (EventPipeThread, CrstStatic, vcrt PTD), so
/// recreating threads on each work burst would grow native memory without
/// bound. Items are processed FIFO; cancelled items are skipped at dequeue time.
/// </summary>
internal sealed partial class DedicatedThreadPool : IDisposable
{
private const int DrainTimeoutMs = 3000;
private readonly BlockingCollection<Action> _workQueue = new();
private readonly int _minThreads;
private readonly int _maxThreads;
private readonly TimeSpan _idleTimeout;
private readonly string _name;
@@ -41,7 +43,6 @@ internal sealed partial class DedicatedThreadPool : IDisposable
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(minThreads);
ArgumentOutOfRangeException.ThrowIfLessThan(maxThreads, minThreads);
_minThreads = minThreads;
_maxThreads = maxThreads;
_name = name;
_idleTimeout = idleTimeout ?? TimeSpan.FromSeconds(30);
@@ -103,28 +104,16 @@ internal sealed partial class DedicatedThreadPool : IDisposable
}
// TryTake timed out (no work for idleTimeout).
// If the pool is shutting down, exit; otherwise keep this thread
// alive and re-park in TryTake. We intentionally never shrink below
// the high-water mark: terminating a managed thread leaks per-thread
// native CLR/CRT structures (EventPipeThread, CrstStatic, vcrt PTD)
// that the runtime does not reclaim, so threads are reused for the
// lifetime of the pool instead of being recreated on the next burst.
if (_workQueue.IsCompleted)
{
break;
}
// Try to shrink: exit if we're above the minimum.
// CAS ensures exactly one thread wins each decrement race.
while (true)
{
var count = _threadCount;
if (count <= _minThreads)
{
break; // At minimum — stay alive.
}
if (Interlocked.CompareExchange(ref _threadCount, count - 1, count) == count)
{
return; // Decremented successfully — this thread exits.
}
// Another thread changed _threadCount concurrently; retry.
}
}
Interlocked.Decrement(ref _threadCount);

View File

@@ -42,7 +42,12 @@ internal sealed partial class FallbackUpdateManager : IDisposable
// Per-batch cap on sibling workers
private static readonly int MaxWorkersPerBatch = Math.Max(2, Environment.ProcessorCount / 2);
private readonly ConcurrentDictionary<string, InflightCounter> _inflightFallbacks = new();
// Keyed by the TopLevelViewModel instance (reference identity), NOT by command.Id.
// A fallback item's Id is derived from its Title for extensions that don't supply a
// stable id, and that Title changes on every keystroke. Keying by the mutable Id would
// allocate a fresh entry (+ InflightCounter) per keystroke that never gets reclaimed,
// leaking unboundedly while typing. The VM instance is stable for the command's lifetime.
private readonly ConcurrentDictionary<TopLevelViewModel, InflightCounter> _inflightFallbacks = new();
// Dedicated background threads for fallback COM/RPC calls so they never block the
// ThreadPool. Stuck extensions consume a dedicated thread, not a pool thread.
@@ -95,7 +100,7 @@ internal sealed partial class FallbackUpdateManager : IDisposable
}
var command = commands[i];
var counter = _inflightFallbacks.GetOrAdd(command.Id, static _ => new InflightCounter());
var counter = _inflightFallbacks.GetOrAdd(command, static _ => new InflightCounter());
if (!counter.TryClaim(MaxInflightPerFallback))
{
// At capacity — store this query as a pending retry so it runs