Scoping: Isolate the ambient scope stacks across execution context flows (closes #23366) - #23492
Conversation
…n flows Popping the last entry leaves the ConcurrentStack instance in place, so any scope used before hosted services start leaves a non-null but empty stack on the execution context. AsyncLocal copy-on-write protects the reference and not the instance it points at, so every flow branching off that context inherited and mutated the same stack. Concurrent hosted services could therefore observe each other's ambient scope. Scope.Dispose then throws before Locks.ClearLocks runs, leaving the transaction and whatever distributed lock it held open until the process restarts. Allocate a fresh stack when the inherited one is empty, so AsyncLocal copy-on-write isolates the flow. A non-empty stack is genuine nesting within the same flow and is preserved. All three ambient stacks share the defect and need the same fix. Isolating only AmbientScopeStack moves the corruption into AmbientScopeContextStack: scopes that stop being wrongly treated as nested start pushing a scope context of their own, onto a stack that is still shared between flows, until a Pop finds it empty and throws "No AmbientContext was found". Fixes umbraco#23336 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Hi there @Ron-Brouwer, thank you for this contribution! 👍 While we wait for one of the Core Collaborators team to have a look at your work, we wanted to let you know about that we have a checklist for some of the things we will consider during review:
Don't worry if you got something wrong. We like to think of a pull request as the start of a conversation, we're happy to provide guidance on improving your contribution. If you realize that you might want to make some changes then you can do that by adding new commits to the branch you created for this work and pushing new commits. They should then automatically show up as updates to this pull request. Thanks, from your friendly Umbraco GitHub bot 🤖 🙂 |
There was a problem hiding this comment.
Pull request overview
This PR fixes a concurrency defect in Umbraco’s ambient scoping infrastructure where unrelated execution flows (notably concurrently running hosted services) could end up sharing the same mutable ConcurrentStack via AsyncLocal, causing scope/context interleaving, invalid ambient state, and (worst case) stranded transactions/locks until process restart.
Changes:
- Update
Push(...)in all three ambient stacks to allocate a fresh stack when the inherited stack isnullor empty, allowingAsyncLocalcopy-on-write to correctly isolate sibling execution flows. - Add unit tests covering isolation vs. true nesting for
AmbientScopeStackandAmbientScopeContextStack. - Add an EF Core integration test covering the same isolation vs. nesting behavior for
AmbientEFCoreScopeStack.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
src/Umbraco.Infrastructure/Scoping/AmbientScopeStack.cs |
Prevents sharing an inherited empty ambient scope stack across execution flows by allocating a new stack on Push. |
src/Umbraco.Infrastructure/Scoping/AmbientScopeContextStack.cs |
Applies the same isolation fix for the ambient scope context stack to avoid cross-flow corruption after scope isolation. |
src/Umbraco.Cms.Persistence.EFCore/Scoping/AmbientEFCoreScopeStack.cs |
Applies the same isolation fix for EF Core ambient scopes to prevent cross-flow stack sharing. |
tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Scoping/AmbientScopeStackTests.cs |
Adds isolation + nesting tests for both ambient scope and ambient context stacks. |
tests/Umbraco.Tests.Integration/Umbraco.Persistence.EFCore/Scoping/AmbientEFCoreScopeStackTests.cs |
Adds isolation + nesting tests for EF Core ambient scope stack behavior. |
Comments suppressed due to low confidence (1)
tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Scoping/AmbientScopeStackTests.cs:114
- The concurrent-flow isolation test in AmbientScopeContextStackTests also leaves pushed contexts on the stack in the Task.Run flows. Since AmbientScopeContextStack uses a static AsyncLocal, this can leak onto ThreadPool execution contexts and cause cross-test contamination. Add a barrier so both flows read AmbientContext before popping, then pop in each flow.
async Task<IScopeContext?> PushThenReadAmbient(
IScopeContext context,
TaskCompletionSource pushed,
Task otherPushed)
{
sut.Push(context);
pushed.SetResult();
await otherPushed;
return sut.AmbientContext;
}
…tion tests Each flow now pops what it pushed, so a test leaves nothing behind on a stack held in a static AsyncLocal, and asserts that nothing leaked into the calling context either. The pop needs its own barrier. Popping straight after the read lets one flow remove the other's entry before that flow reads, which leaves the remaining read coincidentally correct and hides the defect entirely — verified by reverting the fix, at which point the isolation tests still passed. So every flow now reads before any flow pops. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…Barriers Threading two TaskCompletionSource instances and their two counterpart tasks through RunFlow took it to five parameters, past the Clean Code gate's limit of four. FlowBarriers owns the pair and exposes the two rendezvous points by name, so the reason each barrier exists is stated where it is awaited. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… base fixture The two fixtures held the same two tests written twice, once per stack, because the stacks expose no common interface. AmbientStackTestsBase carries both test bodies and reaches each concrete stack through four abstract members, so each fixture is now just those four members. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Hi @Ron-Brouwer Thanks for your PR to fix #23336, where the possibility of a permanently open SQL write transaction was identified. One of the HQ team will review this as soon as possible; likely @AndyButland when he has capacity to pick this up. I think this is your first contribution to this repo? That is awesome! 🎉 Best regards Emma |
Zeegaan
left a comment
There was a problem hiding this comment.
Great changes, and really love the detailed description, makes it really easy to understand 🎉
And the tests is the cherry on top, great job 🍒
…ows (closes #23336) (#23492) * fix(infrastructure): isolate the ambient scope stacks across execution flows Popping the last entry leaves the ConcurrentStack instance in place, so any scope used before hosted services start leaves a non-null but empty stack on the execution context. AsyncLocal copy-on-write protects the reference and not the instance it points at, so every flow branching off that context inherited and mutated the same stack. Concurrent hosted services could therefore observe each other's ambient scope. Scope.Dispose then throws before Locks.ClearLocks runs, leaving the transaction and whatever distributed lock it held open until the process restarts. Allocate a fresh stack when the inherited one is empty, so AsyncLocal copy-on-write isolates the flow. A non-empty stack is genuine nesting within the same flow and is preserved. All three ambient stacks share the defect and need the same fix. Isolating only AmbientScopeStack moves the corruption into AmbientScopeContextStack: scopes that stop being wrongly treated as nested start pushing a scope context of their own, onto a stack that is still shared between flows, until a Pop finds it empty and throws "No AmbientContext was found". Fixes #23336 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(infrastructure): pop within each flow in the ambient stack isolation tests Each flow now pops what it pushed, so a test leaves nothing behind on a stack held in a static AsyncLocal, and asserts that nothing leaked into the calling context either. The pop needs its own barrier. Popping straight after the read lets one flow remove the other's entry before that flow reads, which leaves the remaining read coincidentally correct and hides the defect entirely — verified by reverting the fix, at which point the isolation tests still passed. So every flow now reads before any flow pops. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(infrastructure): encapsulate the isolation test barriers in FlowBarriers Threading two TaskCompletionSource instances and their two counterpart tasks through RunFlow took it to five parameters, past the Clean Code gate's limit of four. FlowBarriers owns the pair and exposes the two rendezvous points by name, so the reason each barrier exists is stated where it is awaited. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(infrastructure): share the ambient stack tests through a generic base fixture The two fixtures held the same two tests written twice, once per stack, because the stacks expose no common interface. AmbientStackTestsBase carries both test bodies and reaches each concrete stack through four abstract members, so each fixture is now just those four members. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit 36e1b66)
…ows (closes #23336) (#23492) * fix(infrastructure): isolate the ambient scope stacks across execution flows Popping the last entry leaves the ConcurrentStack instance in place, so any scope used before hosted services start leaves a non-null but empty stack on the execution context. AsyncLocal copy-on-write protects the reference and not the instance it points at, so every flow branching off that context inherited and mutated the same stack. Concurrent hosted services could therefore observe each other's ambient scope. Scope.Dispose then throws before Locks.ClearLocks runs, leaving the transaction and whatever distributed lock it held open until the process restarts. Allocate a fresh stack when the inherited one is empty, so AsyncLocal copy-on-write isolates the flow. A non-empty stack is genuine nesting within the same flow and is preserved. All three ambient stacks share the defect and need the same fix. Isolating only AmbientScopeStack moves the corruption into AmbientScopeContextStack: scopes that stop being wrongly treated as nested start pushing a scope context of their own, onto a stack that is still shared between flows, until a Pop finds it empty and throws "No AmbientContext was found". Fixes #23336 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(infrastructure): pop within each flow in the ambient stack isolation tests Each flow now pops what it pushed, so a test leaves nothing behind on a stack held in a static AsyncLocal, and asserts that nothing leaked into the calling context either. The pop needs its own barrier. Popping straight after the read lets one flow remove the other's entry before that flow reads, which leaves the remaining read coincidentally correct and hides the defect entirely — verified by reverting the fix, at which point the isolation tests still passed. So every flow now reads before any flow pops. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(infrastructure): encapsulate the isolation test barriers in FlowBarriers Threading two TaskCompletionSource instances and their two counterpart tasks through RunFlow took it to five parameters, past the Clean Code gate's limit of four. FlowBarriers owns the pair and exposes the two rendezvous points by name, so the reason each barrier exists is stated where it is awaited. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(infrastructure): share the ambient stack tests through a generic base fixture The two fixtures held the same two tests written twice, once per stack, because the stacks expose no common interface. AmbientStackTestsBase carries both test bodies and reaches each concrete stack through four abstract members, so each fixture is now just those four members. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit 36e1b66)
…ows (closes #23336) (#23492) * fix(infrastructure): isolate the ambient scope stacks across execution flows Popping the last entry leaves the ConcurrentStack instance in place, so any scope used before hosted services start leaves a non-null but empty stack on the execution context. AsyncLocal copy-on-write protects the reference and not the instance it points at, so every flow branching off that context inherited and mutated the same stack. Concurrent hosted services could therefore observe each other's ambient scope. Scope.Dispose then throws before Locks.ClearLocks runs, leaving the transaction and whatever distributed lock it held open until the process restarts. Allocate a fresh stack when the inherited one is empty, so AsyncLocal copy-on-write isolates the flow. A non-empty stack is genuine nesting within the same flow and is preserved. All three ambient stacks share the defect and need the same fix. Isolating only AmbientScopeStack moves the corruption into AmbientScopeContextStack: scopes that stop being wrongly treated as nested start pushing a scope context of their own, onto a stack that is still shared between flows, until a Pop finds it empty and throws "No AmbientContext was found". Fixes #23336 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(infrastructure): pop within each flow in the ambient stack isolation tests Each flow now pops what it pushed, so a test leaves nothing behind on a stack held in a static AsyncLocal, and asserts that nothing leaked into the calling context either. The pop needs its own barrier. Popping straight after the read lets one flow remove the other's entry before that flow reads, which leaves the remaining read coincidentally correct and hides the defect entirely — verified by reverting the fix, at which point the isolation tests still passed. So every flow now reads before any flow pops. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(infrastructure): encapsulate the isolation test barriers in FlowBarriers Threading two TaskCompletionSource instances and their two counterpart tasks through RunFlow took it to five parameters, past the Clean Code gate's limit of four. FlowBarriers owns the pair and exposes the two rendezvous points by name, so the reason each barrier exists is stated where it is awaited. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(infrastructure): share the ambient stack tests through a generic base fixture The two fixtures held the same two tests written twice, once per stack, because the stacks expose no common interface. AmbientStackTestsBase carries both test bodies and reaches each concrete stack through four abstract members, so each fixture is now just those four members. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit 36e1b66)
…references The Umbraco.Cms.Search.Core and Umbraco.Cms.Search.Provider.Examine references slipped into the dev site's csproj with the cherry-pick of #23492 (89bfe9d) - they were never part of that PR. They are harmless on v17 but the v17 assemblies reference types removed in v18, so every merge-up to main breaks the dev site's boot with a ReflectionTypeLoadException until they are stripped again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Description
All three ambient scope stacks share a
ConcurrentStackinstance between unrelated execution flows, so concurrently-running hosted services can observe each other's ambient scope. When that happensScope.Dispose()throws before it releases anything, stranding the transaction and whatever distributed lock it held until the process restarts.Related: #23366, which reports the same defect becoming reachable after #22331.
Root cause
Pushdoes(_stack.Value ??= new ConcurrentStack<IScope>()).Push(scope), andPopnever resets_stack.Valuetonull— popping the last entry leaves the instance in place. So the first scope used on any execution context leaves behind a non-null but empty stack.AsyncLocalcopy-on-write protects the reference, not the instance it points at. Every flow that later branches off that context therefore inherits and mutates the sameConcurrentStack. Because aConcurrentStackis globally LIFO rather than per-flow, pushes and pops from unrelated hosted services interleave.This is why the failure is timing-sensitive. If
_stack.Valuehappens to benullwhen a service starts, that service allocates its own stack and behaves correctly; if something had already used a scope on that context —DistributedJobService.EnsureJobsAsyncduring start-up, for instance — it silently shares.Why a failed dispose strands the lock
When the mismatch is detected,
Scope.Dispose()throws beforeLocks.ClearLocks(InstanceId), beforeLocks.EnsureLocksCleared(InstanceId), and before_scopeProvider.PopAmbientScope(). So a failed dispose leaves the locks held, the transaction uncompleted, and the stack still corrupted for everything that follows. On SQL Server that strands the lock row; on SQLite it leaves an open write transaction.There is a quieter second symptom.
Scope.Databasefetches the parent's database whenParentScope != nulland callsGetCurrentTransactionIsolationLevel(), which touchesTransaction.IsolationLevel. A scope wrongly parented to a foreign scope whose transaction has since completed throwsInvalidOperationException: This SqlTransaction has completed. Worth noting because such a scope also does not own its transaction, so itsscope.Complete()becomes a no-op against the real one — a correctness exposure, not only a liveness one.All three stacks need the fix
A partial fix is worse than no fix. We deployed a build with only
AmbientScopeStackfixed to an affected production site and immediately got a new exception:Whether a scope pushes a scope context depends on whether it sees an ambient scope. Before the fix, flows sharing a stack wrongly saw each other's scopes and became nested children, which do not push a context. Once the scope stack is isolated those scopes correctly become root scopes and start pushing contexts of their own — onto a context stack that is still shared between flows. They then pop each other's entries until one finds it empty and throws. The corruption moves one layer down rather than disappearing.
The change
Identical in all three: when the inherited stack is empty there is no genuine nesting to preserve, so allocating a fresh one lets
AsyncLocalcopy-on-write isolate the flow. A non-empty stack is real nesting within the same flow and is kept, so a child scope still nests under the current ambient one.Why not restore
ExecutionContext.SuppressFlow()instead#22331 removed
SuppressFlowfromRecurringHostedServiceBaseand simultaneously added an equivalent toRecurringBackgroundJobHostedService.StartAsync. The protection was relocated rather than deleted, so theIRecurringBackgroundJobwrapper stayed safe while external subclasses of the publicRecurringHostedServiceBaselost protection they had in 17.4.Restoring it there would close that regression, but it is not sufficient:
DistributedBackgroundJobHostedServicehas never hadSuppressFlowin any version, including currentmain, and it participates in most of the traces we collected.AmbientScopeStack.csis byte-identical acrossrelease-17.4.2,release-17.5.3andmain, so the defect is version-invariant rather than a regression.Fixing the stacks covers every caller at once, including third-party hosted services, instead of requiring each entry point to remember to suppress flow. It is also the only option available to affected sites, since
IAmbientScopeStackandAmbientScopeContextStackareinternal— the DI registration cannot be overridden from a consumer project, so there is no supported workaround.Affected versions
Confirmed in production on 17.5.2 and 17.5.3, and present unchanged on
main. This PR targetsmainper the contributing guide, but it cherry-picks cleanly to the v17 branches:AmbientScopeStack.csandAmbientScopeContextStack.csare byte-identical betweenmainandv17/dev.AmbientEFCoreScopeStack.csdiffers only by theIEfCoreScope→IEFCoreScoperename, so the v17 variant needs the lower-case spelling in both the fix and the new test.How to test
Run the new tests:
To confirm the tests actually catch the defect, revert the three
Pushmethods to_stack.Value ??= new ConcurrentStack<…>()and re-run. Each stack has one isolation test that fails and one nesting guard that passes either way, so the guards cannot mask an over-correction:~AmbientScopeEach isolation test reproduces the defect deterministically: it leaves a non-null empty stack on the execution context, starts two flows from it, has both push and wait for each other, then asserts each flow still sees its own entry as ambient.
Wider suites on this branch: 6,393 unit tests and 106 scoping integration tests passing, 0 failures (6 and 3 skipped respectively, unrelated).
For a real-world reproduction, run two hosted services concurrently —
DistributedBackgroundJobHostedServiceplus anyRecurringHostedServiceBasesubclass that opens a scope — on a site where a scope was used during start-up. Before the fix, one of them eventually throws"The Scope … is not the Ambient Scope …"fromScope.Dispose(), after which every server polling the-347DistributedJobs lock times out every few seconds indefinitely, because the poll interval and the write-lock timeout are both 5 seconds.Fixes #23336