Fix PT Run ThreadPool worker leak from stale query cancellation - #48394
Fix PT Run ThreadPool worker leak from stale query cancellation#48394crutkas wants to merge 30 commits into
Conversation
QueryResults in MainViewModel cancels the previous query by replacing the _updateToken field with a fresh CancellationToken on every keystroke. However, the running Task body and its Parallel.ForEach calls read _updateToken via field access (not closure capture), so after the field is reassigned, ThrowIfCancellationRequested() reads the NEW non-cancelled token and the previous query runs to completion. Over time the leaked in-flight queries (and their per-keystroke fan-out across all plugins via Parallel.ForEach) saturate the ThreadPool worker creation budget, ending in System.OutOfMemoryException raised by Thread.StartInternal. Additionally, _updateSource was Dispose()d immediately after Cancel(), which races with in-flight consumers; they can observe ObjectDisposedException (not caught) instead of OperationCanceledException. Changes: - Capture the new token into a local variable updateToken and use that inside the Task lambda and both Parallel.ForEach calls, so field reassignment does not silently mask cancellation. - Defer Dispose of the previous CancellationTokenSource via ContinueWith on the previous query Task (tracked via a new _currentQueryTask field). - Pass the cancellation token to Parallel.ForEach via ParallelOptions so the loop honors cancellation between iterations as well as inside them. - Pass explicit TaskScheduler.Default to Task.Factory.StartNew / ContinueWhenAll to avoid the TaskScheduler.Current footgun. Related: issue microsoft#36041, plus auto-closed reports microsoft#45704, microsoft#36587, microsoft#39942, microsoft#20264, microsoft#8878. Co-authored-by: Copilot <[email protected]>
This comment has been minimized.
This comment has been minimized.
|
@copilot fix the spellbot error. betting as simple as adding a comma to "Otherwise" so it becomes "Otherwise," |
Builds on the original capture-token-locally fix by addressing residual issues surfaced in review: * SRP: new internal sealed QuerySession owns ONE CancellationTokenSource and the tail Task it spawns. Construct-once via QuerySession.Start (pipelineFactory); post-construction the only surface is terminal idempotent ops (Cancel / DisposeWhenComplete / CancelAndWait / Dispose). Token is an immutable struct snapshot bound to THIS session's CTS, so the field-reassignment bug at the root of microsoft#36041 is structurally prevented for any caller that uses session.Token (regression-tested below). * Task.Run instead of Task.Factory.StartNew(None, Default): equivalent scheduler/option pinning but also passes DenyChildAttach, preventing plugin-spawned attached child tasks from extending the parent's lifetime past cancellation. * DenyChildAttach on the doFinalSort ContinueWith for the same reason; the continuation is composed inside the pipeline factory so it is part of the session's Completion and the previous CTS isn't disposed before final sort observes cancellation. * Token capture in QueryHistory and RegisterResultsUpdatedEvent: those paths previously read the field; both now snapshot session.Token into a local before the async work. * Removed ExecuteSynchronously from the QueryResults ContinueWith: per docs (only short-running continuations should run synchronously) it could schedule Dispose on the UI thread when the antecedent was already completed at the point ContinueWith was called. * App shutdown: MainViewModel.Dispose now calls _currentSession?.CancelAndWait(2s) to stop the in-flight query before the CTS is disposed, instead of leaking it. * Corrected the inline rationale comment: CancellationToken.ThrowIfCancellationRequested raises OperationCanceledException, never ObjectDisposedException — only Register / WaitHandle can throw ODE post-dispose. * New Wox.Test/QuerySessionTest.cs (9 tests) including the canonical regression for microsoft#36041 — CapturedToken_StaysBoundToOriginalSourceAcrossReplacement — proving that cancelling session A does not affect session B's token and that a captured local snapshot of A.Token observes A's cancellation even after _currentSession has been reassigned. Wox.Test: 139/139 passing.
|
Pushed What this commit adds on top of yoursYour local-capture fix (
New regression tests (
|
| Test | Asserts |
|---|---|
CapturedToken_StaysBoundToOriginalSourceAcrossReplacement |
Canonical #36041 regression. Creates session A, captures firstToken, creates session B, cancels A — asserts firstToken.IsCancellationRequested == true AND B.Token.IsCancellationRequested == false. |
Cancel_SignalsCapturedTokenAndIsIdempotent |
Cancel() is idempotent; captured-local token observes cancellation. |
Start_PassesSessionTokenToPipelineFactory |
The factory receives THIS session's token. |
Start_ThrowsArgumentNullException_WhenFactoryIsNull |
Defensive contract. |
DisposeWhenComplete_WaitsForCompletionTaskBeforeDisposingCts |
CTS is not disposed until the tracked completion task finishes. |
CancelAndWait_ReturnsTrueWhenTaskCompletesWithinTimeout |
Shutdown path success case. |
CancelAndWait_ReturnsFalseWhenTaskExceedsTimeout |
Shutdown path timeout case (buggy plugin doesn't yield). |
Dispose_IsIdempotent_AndSafeAfterCancel |
No double-dispose / no throw. |
Token_ThrowIfCancellationRequested_NeverThrowsObjectDisposedException |
Documents the property #7 above corrects. |
Wox.Test: 139/139 passing.
Suggested manual smoke
- Bug repro — hold a key in PT Run for 10–15s; watch
PowerToys.PowerLauncher.exethread count in Task Manager. Should stabilize, not climb monotonically. - Normal queries — Calculator (
=2+2), file search, web search, indexer. - Final-sort path — Settings → PT Run → enable "Search query tuning" + "Wait for slow results", then query a slow plugin. Results should appear, then re-sort.
- Race — type a slow query, then type more before it completes. Only the final query's results should remain.
- Shutdown — Exit PowerToys with an in-flight query. Should exit cleanly in ≤2s, no orphaned processes.
Happy to back any of this out if you want a smaller diff — the SRP extraction is the bulk of it.
…ispose on CancelAndWait timeout Two fixes spotted while doing a follow-up read of the cancellation refactor for an unrelated review: 1. MainViewModel.cs — previous session was only cancelled when pluginQueryPairs had at least one entry. A non-empty QueryText whose QueryBuilder.Build() returns an empty dictionary (e.g. all global plugins disabled, no keyword match) fell through both cancellation paths and left the in-flight session running. The pre-refactor code cancelled _updateSource unconditionally before the Count check; this restores that invariant by hoisting previousSession.Cancel() + DisposeWhenComplete() out of the Count > 0 block while keeping _currentSession bound to the just-cancelled session (so late IResultUpdated events continue to suppress via their captured token rather than falling back to CancellationToken.None). 2. QuerySession.CancelAndWait — on timeout, the CTS was disposed unconditionally while the tracked task may still have been running. That violates the refactor's own "tasks never observe a disposed CTS" invariant: any future plugin path that touches token WaitHandles (Register, WaitOne) after timeout would crash with ObjectDisposedException. The fix gates dispose on the completed flag and, on timeout, defers disposal via ContinueWith with DenyChildAttach (mirroring DisposeWhenComplete's pattern). Also softens the QuerySession remarks: the "structural guarantee" only holds for callers that capture Token into a local. The class can't prevent a future author from re-reading _currentSession.Token inside a body, which would reintroduce the original bug. Added regression test CancelAndWait_DoesNotDisposeCtsWhileTaskStillRuns covering the new deferred-disposal behavior; all 10 Wox.Test QuerySessionTest cases pass. --- ADO: https://microsoft.visualstudio.com/DefaultCollection/OS/_workitems/edit/55588441/ Co-authored-by: Copilot <[email protected]>
QueryResults in MainViewModel cancels the previous query by replacing the _updateToken field with a fresh CancellationToken on every keystroke. However, the running Task body and its Parallel.ForEach calls read _updateToken via field access (not closure capture), so after the field is reassigned, ThrowIfCancellationRequested() reads the NEW non-cancelled token and the previous query runs to completion. Over time the leaked in-flight queries (and their per-keystroke fan-out across all plugins via Parallel.ForEach) saturate the ThreadPool worker creation budget, ending in System.OutOfMemoryException raised by Thread.StartInternal. Additionally, _updateSource was Dispose()d immediately after Cancel(), which races with in-flight consumers; they can observe ObjectDisposedException (not caught) instead of OperationCanceledException. Changes: - Capture the new token into a local variable updateToken and use that inside the Task lambda and both Parallel.ForEach calls, so field reassignment does not silently mask cancellation. - Defer Dispose of the previous CancellationTokenSource via ContinueWith on the previous query Task (tracked via a new _currentQueryTask field). - Pass the cancellation token to Parallel.ForEach via ParallelOptions so the loop honors cancellation between iterations as well as inside them. - Pass explicit TaskScheduler.Default to Task.Factory.StartNew / ContinueWhenAll to avoid the TaskScheduler.Current footgun. Related: issue microsoft#36041, plus auto-closed reports microsoft#45704, microsoft#36587, microsoft#39942, microsoft#20264, microsoft#8878. Co-authored-by: Copilot <[email protected]>
Builds on the original capture-token-locally fix by addressing residual issues surfaced in review: * SRP: new internal sealed QuerySession owns ONE CancellationTokenSource and the tail Task it spawns. Construct-once via QuerySession.Start (pipelineFactory); post-construction the only surface is terminal idempotent ops (Cancel / DisposeWhenComplete / CancelAndWait / Dispose). Token is an immutable struct snapshot bound to THIS session's CTS, so the field-reassignment bug at the root of microsoft#36041 is structurally prevented for any caller that uses session.Token (regression-tested below). * Task.Run instead of Task.Factory.StartNew(None, Default): equivalent scheduler/option pinning but also passes DenyChildAttach, preventing plugin-spawned attached child tasks from extending the parent's lifetime past cancellation. * DenyChildAttach on the doFinalSort ContinueWith for the same reason; the continuation is composed inside the pipeline factory so it is part of the session's Completion and the previous CTS isn't disposed before final sort observes cancellation. * Token capture in QueryHistory and RegisterResultsUpdatedEvent: those paths previously read the field; both now snapshot session.Token into a local before the async work. * Removed ExecuteSynchronously from the QueryResults ContinueWith: per docs (only short-running continuations should run synchronously) it could schedule Dispose on the UI thread when the antecedent was already completed at the point ContinueWith was called. * App shutdown: MainViewModel.Dispose now calls _currentSession?.CancelAndWait(2s) to stop the in-flight query before the CTS is disposed, instead of leaking it. * Corrected the inline rationale comment: CancellationToken.ThrowIfCancellationRequested raises OperationCanceledException, never ObjectDisposedException — only Register / WaitHandle can throw ODE post-dispose. * New Wox.Test/QuerySessionTest.cs (9 tests) including the canonical regression for microsoft#36041 — CapturedToken_StaysBoundToOriginalSourceAcrossReplacement — proving that cancelling session A does not affect session B's token and that a captured local snapshot of A.Token observes A's cancellation even after _currentSession has been reassigned. Wox.Test: 139/139 passing.
…ispose on CancelAndWait timeout Two fixes spotted while doing a follow-up read of the cancellation refactor for an unrelated review: 1. MainViewModel.cs — previous session was only cancelled when pluginQueryPairs had at least one entry. A non-empty QueryText whose QueryBuilder.Build() returns an empty dictionary (e.g. all global plugins disabled, no keyword match) fell through both cancellation paths and left the in-flight session running. The pre-refactor code cancelled _updateSource unconditionally before the Count check; this restores that invariant by hoisting previousSession.Cancel() + DisposeWhenComplete() out of the Count > 0 block while keeping _currentSession bound to the just-cancelled session (so late IResultUpdated events continue to suppress via their captured token rather than falling back to CancellationToken.None). 2. QuerySession.CancelAndWait — on timeout, the CTS was disposed unconditionally while the tracked task may still have been running. That violates the refactor's own "tasks never observe a disposed CTS" invariant: any future plugin path that touches token WaitHandles (Register, WaitOne) after timeout would crash with ObjectDisposedException. The fix gates dispose on the completed flag and, on timeout, defers disposal via ContinueWith with DenyChildAttach (mirroring DisposeWhenComplete's pattern). Also softens the QuerySession remarks: the "structural guarantee" only holds for callers that capture Token into a local. The class can't prevent a future author from re-reading _currentSession.Token inside a body, which would reintroduce the original bug. Added regression test CancelAndWait_DoesNotDisposeCtsWhileTaskStillRuns covering the new deferred-disposal behavior; all 10 Wox.Test QuerySessionTest cases pass. --- ADO: https://microsoft.visualstudio.com/DefaultCollection/OS/_workitems/edit/55588441/ Co-authored-by: Copilot <[email protected]>
Copilot/review pull 48394
…ellation-oom fix(run): prevent canceled query task buildup
|
Thank you for contributing to PowerToys. We've detected that this PR might include a new or modified telemetry event. Please ensure the following before merging:
|
fix: resolve StyleCop SA1214, SA1508, and CA1068 errors in PT Run
Co-authored-by: Copilot App <[email protected]> Copilot-Session: 54e1bb28-edae-496b-8211-0e1592ddc985
Co-authored-by: Copilot App <[email protected]> Copilot-Session: 54e1bb28-edae-496b-8211-0e1592ddc985
Co-authored-by: Copilot App <[email protected]> Copilot-Session: 54e1bb28-edae-496b-8211-0e1592ddc985
Summary
Fixes a ThreadPool worker leak in PowerToys Run that can eventually surface as
System.OutOfMemoryExceptionfromThread.StartInternalafter rapid typing and repeated stale-query cancellation.Related: #36041 and duplicate reports #45704, #36587, #39942, #20264, and #8878.
Root cause
MainViewModel.QueryResultsstored the active cancellation token in a mutable field. When a new query replaced that field, older workers could observe the new, non-cancelled token instead of the token belonging to their own query. The previousCancellationTokenSourcewas also disposed while its consumers could still be running.As stale queries accumulated, they continued invoking plugins and consuming ThreadPool workers until the process could no longer create another worker thread.
Changes
QuerySession, which owns one captured token and the complete task lifetime for a query. Superseded sessions are cancelled immediately and their token sources are disposed only after their work completes.IResultUpdatedcompatibility by correlating generation-0 events usingRawQuery.noInitialResultsis computed from the complete non-delayed phase.Tests
Wox.Test: 142/142 passing locally.Coverage includes:
Manual validation