Release abandoned orchestrator tasks when an executor is retired - #1390
Open
wangbill (YunchuWang) wants to merge 1 commit into
Open
Release abandoned orchestrator tasks when an executor is retired#1390wangbill (YunchuWang) wants to merge 1 commit into
wangbill (YunchuWang) wants to merge 1 commit into
Conversation
TaskOrchestrationContext creates a TaskCompletionSource<string> per activity, sub-orchestration and timer, and the orchestrator awaits it. When an episode ends while the orchestrator is parked on one of those awaits, the TCS is deliberately abandoned in a pending state -- that is inherent to the replay model. When a debugger is attached, TaskAwaiter.OnCompletedInternal calls OutputWaitEtwEvents, which calls Task.AddToActiveTasks because Task.s_asyncDebuggingEnabled is true. That inserts the task into the process-wide, strongly-referenced static dictionary Task.s_currentActiveTasks. The matching Task.RemoveFromActiveTasks only runs inside the awaiter continuation wrapper, i.e. only when the awaited task completes. Abandoned tasks are therefore rooted forever, and each rooted Task pins its continuation -> async state machine -> TaskOrchestrationContext -> history/inputs/outputs. Only the AddToActiveTasks branch roots the task, which is why the leak is debugger-specific. Fix: cancel the open tasks once the executor is guaranteed never to run again, so the abandoned awaiter continuations run and unregister themselves. - TaskOrchestrationContext.ReleaseOpenTasks() cancels every open task. It is idempotent, sets isReleased before cancelling, and snapshots openTasks before iterating because resumed user code can mutate the dictionary. A ThrowIfReleased guard on ScheduleTaskInternal, CreateSubOrchestrationInstanceCore and CreateTimer stops orchestrator code that swallows the cancellation from scheduling new work. - TaskOrchestrationExecutor implements IDisposable. Dispose() restores the orchestrator ambient environment, releases the open tasks, and observes a faulted result so nothing surfaces as an UnobservedTaskException. - TaskOrchestrationDispatcher owns executor lifetime via workItem.Cursor, so it retires the executor from a new finally on OnProcessWorkItemSessionAsync and at the continue-as-new site. Extended sessions are unaffected: release only happens when the executor is retired, never between episodes. ExtendedSession_OpenTasksSurviveBetweenEpisodes covers this. Ref Azure/azure-functions-durable-extension#340 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR addresses a debugger-specific memory leak in DurableTask.Core by ensuring that “abandoned” orchestrator awaits (open activity/sub-orchestration/timer tasks) are released when an orchestration executor is retired, allowing CLR async-debug bookkeeping (Task.s_currentActiveTasks) to unregister those tasks.
Changes:
- Add
TaskOrchestrationContext.ReleaseOpenTasks()and a released-context guard to prevent scheduling new work after release. - Make
TaskOrchestrationExecutorimplementIDisposableand release open tasks on disposal while restoring orchestrator ambient execution context. - Ensure
TaskOrchestrationDispatcherdisposes retired executors via a centralizedReleaseCursor(...)helper.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| Test/DurableTask.Core.Tests/TaskOrchestrationExecutorTests.cs | Adds regression tests for executor retirement/leak behavior, but currently placed in the legacy Test/ tree (not in the active test project). |
| src/DurableTask.Core/TaskOrchestrationExecutor.cs | Implements IDisposable to release abandoned orchestrator continuations when the executor is retired. |
| src/DurableTask.Core/TaskOrchestrationDispatcher.cs | Disposes retired executors in finally at session end and when continuing-as-new, via ReleaseCursor. |
| src/DurableTask.Core/TaskOrchestrationContext.cs | Adds open-task cancellation (ReleaseOpenTasks) and prevents scheduling new open tasks after release (ThrowIfReleased). |
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+14
to
+20
| namespace DurableTask.Core.Tests | ||
| { | ||
| using System; | ||
| using System.Collections.Generic; | ||
| using System.Linq; | ||
| using System.Reflection; | ||
| using System.Threading.Tasks; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release abandoned orchestrator tasks when an executor is retired
Fixes the long-standing "memory leak when debugger attached" reported in
Azure/azure-functions-durable-extension#340. The issue is filed on the
extension repo but is labeled
dtfx/externalbecause the root cause is here, inDurableTask.Core.Root cause
TaskOrchestrationContextcreates aTaskCompletionSource<string>for every activity,sub-orchestration and timer, registers it in
openTasks, and the orchestratorawaitstcs.Task.When an episode ends while the orchestrator is parked on one of those awaits, the TCS is deliberately
abandoned in a pending state. That is inherent to the replay model and is normally harmless.
It stops being harmless when a debugger is attached:
TaskAwaiter.OnCompletedInternalcallsOutputWaitEtwEventsbecauseTask.s_asyncDebuggingEnabledis
true.Task.AddToActiveTasks(task), which inserts the task into the process-wide,strongly referenced static dictionary
Task.s_currentActiveTasks.Task.RemoveFromActiveTasksonly runs inside the awaiter's continuation wrapper — thatis, only when the awaited task completes.
An abandoned task never completes, so it is rooted forever. Each rooted
Taskpins its continuation →async state machine →
TaskOrchestrationContext→ history, inputs and outputs. Notably only theAddToActiveTasksbranch roots anything (the ETW branch does not), which is exactly why the leak isdebugger-specific.
The fix
Cancel the open tasks once the executor is guaranteed never to run again, so the abandoned awaiter
continuations actually run and unregister themselves.
TaskOrchestrationContextgainsinternal void ReleaseOpenTasks(), which cancels every open task.It is idempotent, sets
isReleased = truebefore cancelling, and snapshotsopenTasks.Valuesbeforeiterating — resumed user code can mutate the dictionary, e.g.
CreateTimer's cancellation-tokencallback calls
openTasks.Remove. A newThrowIfReleased()guard at the top ofScheduleTaskInternal,CreateSubOrchestrationInstanceCoreandCreateTimerstops orchestrator code that swallows thecancellation from scheduling new work that would leak the same way.
TaskOrchestrationExecutorimplementsIDisposable.Dispose()restores the orchestrator ambientenvironment (
TaskOrchestrationSynchronizationContext+OrchestrationContext.IsOrchestratorThread)because cancelling resumes orchestrator code synchronously, calls
context.ReleaseOpenTasks(), andobserves
this.result.Exceptionif faulted so nothing surfaces as anUnobservedTaskException.TaskOrchestrationDispatcherowns executor lifetime viaworkItem.Cursor, so it is the onlycomponent that knows when an executor will never run again. A new
static void ReleaseCursor(ref OrchestrationExecutionCursor? cursor)is called from a newfinallyonthe outer
tryofOnProcessWorkItemSessionAsync— this covers both the legacySession == nullpathand the session path, since
workItem.Cursoris only ever assigned inOnProcessWorkItemAsync, whichboth paths call. It also replaces the bare
workItem.Cursor = null;at the continue-as-new site.Results
Direct-executor harness, 2000 episodes × fan-out 100, debugger simulated by setting
Task.s_asyncDebuggingEnabled:Task.s_currentActiveTasksWith no debugger both are 0, confirming the debugger-specific mechanism.
End to end through the real
TaskHubWorker/TaskOrchestrationDispatcherwithLocalOrchestrationService, 200 instances × 3 activities:The 4,200 break down as 600 each of
TaskOrchestration`4.<Execute>, the user orchestrator's<RunTask>,ScheduleTask, bothScheduleTaskToWorkeroverloads,ScheduleTaskInternal, plus ~600 TCStasks — exactly 3 abandoned awaits per instance. (A residual ~1,200
Task.Delaypromises appearidentically in both runs; that is the emulator's own queue polling and is unrelated.)
Two things reviewers will care about
1.
TaskOrchestrationExecutoris public and now implementsIDisposable. This is both source andbinary compatible — no existing member changed and no caller is required to do anything. Be aware that
it may newly trip CA2000 ("dispose objects before losing scope") in downstream repos that construct
a
TaskOrchestrationExecutorand have that analyzer enabled.2. Extended sessions are explicitly unaffected. Release only happens when the executor is retired,
never between episodes. This is deliberate: cancelling at the end of every episode inside
ExecuteCorewould break extended sessions, because
HandleTaskCompletedEventcallsinfo.Result.SetResult(...),which throws
InvalidOperationExceptionon an already-cancelled TCS. There is a regression test namedExtendedSession_OpenTasksSurviveBetweenEpisodesproving open tasks still survive between episodes.Alternatives considered and rejected
(
Task.WhenAll, awaiting the returnedTask<T>) still register.s_currentActiveTasks— uses private API and cannot reach theintermediate async-method tasks.
A note on the leak-detection test
Dispose_WithAsyncDebuggingEnabled_DoesNotLeakActiveTasksdeliberately measures howTask.s_currentActiveTasksgrowth scales with fan-out (1 vs 25 abandoned awaits over 20 episodes)rather than asserting an absolute count. Two reasons: the dictionary is process-wide, so the test host's
own async plumbing pollutes it; and on .NET Framework an async method's task is not an
AsyncStateMachineBox<T>, so entries cannot be attributed by type there. The test is self-validating —it first asserts that undisposed executors DO leak proportionally, so it can never silently stop
detecting the regression.
Testing
All green in
Debug(the test projects only build in Debug:tools/DurableTask.propssetsSIGN_ASSEMBLYin Release, andAssemblyInfo.cswrapsInternalsVisibleToin#if !SIGN_ASSEMBLY).DurableTask.Core.TestsDurableTask.Emulator.TestsTaskOrchestrationExecutorTestsThe 7 net48 failures are all in
ContinueAsNewTraceBehaviorTests, a file this PR does not touch. Iverified they are pre-existing on
mainby stashing this change and re-running that class on net48 —the same 7 fail identically at baseline.
Full solution build: 0 warnings, 0 errors.
Known follow-up (not fixed here)
azure-functions-durable-extensionhas its own secondary TCS leaks of the same shape that this changedoes not cover, because an orchestrator parked purely on an external event has no DTFx open tasks:
DurableOrchestrationContext.pendingExternalEvents(EventTaskCompletionSource<T>)TaskCommonShim.timeoutTaskCompletionSourceThose need an equivalent release hook in the extension repo.