Replace the RequestManager AsyncLocal guard with an explicit operation context - #4163
Merged
marcschier merged 4 commits intoAug 3, 2026
Merged
Conversation
…n context RequestManager tracked whether the calling flow was serving a Client request in an AsyncLocal<bool>, set by a StandardServer.ProcessRequestAsync override and read by NodeManagerLifecycle so a lifecycle operation started from inside a request fails fast instead of draining - and therefore waiting for - its own request. An AsyncLocal written inside an async method never flows back to the caller that awaited it, so every scope had to chain to the previous value by hand, and the dependency was invisible at the call site. RequestManager also had no business carrying ambient state for a different subsystem. The guard is now an exact identity lookup against the request registry RequestManager already maintains, and the operation is threaded explicitly: - RequestManager.IsExecutingRequest(IOperationContext?) replaces the ambient flag; EnterServiceDispatchScope, ServiceDispatchScope and the flag-based property are gone, as is the ProcessRequestAsync override that set them. - INodeManagerLifecycle gains AddAsync/ReloadAsync/RemoveAsync overloads taking the caller's IOperationContext followed by a required CancellationToken, so they cannot be ambiguous with the existing token-only members. The existing signatures are unchanged for control-plane callers. HostedNodeManagerLifecycle and RuntimeNodeSetLifecycleExtensions forward the new overloads. - IOperationContextProvider and SystemContextExtensions.GetOperationContext let a callback hand over the operation it received without downcasting. - EnsureNotRequestCallback no longer consults ServerState, which could let a re-entrant call slip past while the server was shutting down. An internal OperationContext that was never enrolled as a request is now correctly allowed through, which the boolean flag could not distinguish. A caller inside a request that passes no operation is no longer detected at entry and falls back to the pre-existing bounded RequestDrainTimeout; this is documented in docs/NodeManagers.md. Fixes #4149 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 14402d11-e540-4faa-a5cd-94881bd7ce85
marcschier
commented
Aug 2, 2026
marcschier
marked this pull request as ready for review
August 2, 2026 13:58
Contributor
There was a problem hiding this comment.
Pull request overview
This PR removes the AsyncLocal-based “in service dispatch” guard from RequestManager and replaces it with an explicit operation-context flow and an identity-based lookup against the existing request registry, so lifecycle operations can deterministically reject re-entrant calls without relying on ambient state.
Changes:
- Replace the ambient
AsyncLocal<bool>guard withRequestManager.IsExecutingRequest(IOperationContext?)based on reference identity in the request registry. - Thread
IOperationContext?explicitly through newINodeManagerLifecycleoverloads and propagate through hosted lifecycle and runtime NodeSet helpers. - Introduce
IOperationContextProvider+ISystemContext.GetOperationContext()to extract the serving operation from callback contexts, and add/update tests + docs accordingly.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tests/Opc.Ua.Server.Tests/RequestManagerTests.cs | Updates coverage from EnterServiceDispatchScope to IsExecutingRequest(context) behavior (null/unregistered/active/completed/concurrent). |
| tests/Opc.Ua.Server.Tests/NodeManager/NodeManagerLifecycleTests.cs | Adds scenarios ensuring lifecycle calls reject when invoked on behalf of an executing request and accept non-request/internal contexts. |
| tests/Opc.Ua.Server.Tests/Hosting/HostedNodeManagerLifecycleTests.cs | Verifies forwarding of the new caller-context overloads through HostedNodeManagerLifecycle. |
| tests/Opc.Ua.Core.Tests/Stack/State/SystemContextExtensionsTests.cs | New tests for GetOperationContext() across both SystemContext roots and null-argument behavior. |
| src/Opc.Ua.Types/State/NodeIdFactorySuppressedContext.cs | Implements IOperationContextProvider to forward the wrapped context’s operation. |
| src/Opc.Ua.Types/State/ISystemContext.cs | Adds IOperationContextProvider and SystemContextExtensions.GetOperationContext(this ISystemContext). |
| src/Opc.Ua.Server/Server/StandardServer.cs | Removes the dispatch-scope override and relies on request scope enrollment/identity. |
| src/Opc.Ua.Server/Server/RequestManager.cs | Removes AsyncLocal + scope type; adds registry identity-based IsExecutingRequest(IOperationContext?). |
| src/Opc.Ua.Server/RuntimeNodeSet/RuntimeNodeSetLifecycleExtensions.cs | Adds overloads to pass caller operation context through runtime NodeSet lifecycle helpers. |
| src/Opc.Ua.Server/NodeManager/Lifecycle/NodeManagerLifecycle.cs | Threads caller context through Add/Reload/Remove paths and updates the re-entrancy guard to use the request registry. |
| src/Opc.Ua.Server/NodeManager/Lifecycle/INodeManagerLifecycle.cs | Adds new overloads that accept IOperationContext? + required CancellationToken. |
| src/Opc.Ua.Server/Hosting/HostedNodeManagerLifecycle.cs | Implements/forwards the new overloads on the hosted lifecycle wrapper. |
| src/Opc.Ua.Core/Stack/State/ISessionSystemContext.cs | Makes SessionSystemContext an IOperationContextProvider so callbacks can surface the serving operation. |
| docs/RuntimeNodeSets.md | Documents the new operation-context overloads and guidance for callback callers. |
| docs/NodeManagers.md | Updates lifecycle guidance to use explicit operation context instead of ambient detection. |
Review feedback on #4163: "No overloads, no backcompat needed, just add the argument." The five `INodeManagerLifecycle` members and the two runtime NodeSet extension methods no longer come in two flavours. Each one takes `IOperationContext? callerContext` directly, ahead of `CancellationToken ct = default`. The duplicated declarations, the delegating bodies in `NodeManagerLifecycle`, the duplicate forwarders in `HostedNodeManagerLifecycle` and the pass-through overloads in `RuntimeNodeSetLifecycleExtensions` are gone. Because there is no longer a sibling overload to be ambiguous with, `ct` gets its default back, so a caller that does not cancel writes `AddAsync(factory, null)`. Call sites pass `null` where they are control-plane callers, which is every existing one. Documentation samples are updated to match. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 14402d11-e540-4faa-a5cd-94881bd7ce85
romanett
reviewed
Aug 3, 2026
…w interface Review feedback on #4163: the stack already has more context abstractions than it needs, and every context that implemented the new interface is an IOperationContext already, so introducing another one is not justified. `IOperationContextProvider` is removed, along with its declarations on `SystemContext`, `SessionSystemContext` and `NodeIdFactorySuppressedContext`. `GetOperationContext` moves out of `SystemContextExtensions` in Opc.Ua.Types and into `SystemContextOperationExtensions` in Opc.Ua.Core, which can see both `SystemContext` and `SessionSystemContext` - the two independent roots every context derives from - and switches on them directly. The accessor is kept rather than replaced by a cast: a context implements `IOperationContext` by delegating to the operation it was created for, so casting the context yields the context and not the operation. The guard in `RequestManager.IsExecutingRequest` compares the registered `OperationContext` by reference, so the distinction decides whether it fires at all. `NodeIdFactorySuppressedContext` no longer forwards the wrapped operation. It is constructed only while copying a node, never on a path that reaches the lifecycle API, so nothing observes the difference. Net effect: one public interface fewer than before the feedback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 14402d11-e540-4faa-a5cd-94881bd7ce85
romanett
approved these changes
Aug 3, 2026
The net48 Opc.Ua.Sessions test job failed on ChannelManagerExhaustionEscalatesAndRecoversWhenServerReturns, a timing dependent reconnect test that also flakes on master. Nothing to fix on this branch, so this empty commit only asks the pipeline for another run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 14402d11-e540-4faa-a5cd-94881bd7ce85
cristipogacean
approved these changes
Aug 3, 2026
marcschier
enabled auto-merge (squash)
August 3, 2026 17:49
marcschier
added a commit
that referenced
this pull request
Aug 4, 2026
Reconcile master's explicit request-operation context (#4163, 168b3de) with this branch's NodeManager shadow/immediate reload and request-drain shutdown (#4147). Both master and this branch independently removed an ambient AsyncLocal guard. The re-entrancy guard now uses master's explicit design: RequestManager.IsExecutingRequest(IOperationContext) tests the caller's own context against the executing request set, replacing the removed EnterServiceDispatchScope ambient marker. The branch's request-drain machinery is kept intact and its waiter is correlated through the ambient m_currentRequestId via GetCurrentRequestIdForLifecycleExtension. To keep both designs coherent, EnterRequestLifecycleWaiter now gates on the ambient request id instead of the explicit caller context. This is required because ShadowReloadAsync (and other internal callers) run inside a request scope but pass a null caller context; the old explicit gate left them without a drain waiter, so their graceful retirement deadlocked waiting for their own request to drain. Kept from #4147: ShadowReloadAsync/ImmediateReloadAsync (both factory overloads), IsShuttingDown on INodeManagerLifecycle, the admission/drain shutdown machinery in RequestManagerLifecycleExtension, and NodeManagerReloadCommittedException / INodeManagerReloadParticipant / retired-NodeManager tracking. Ported master's five request-callback guard tests and their two helpers. Adapted ReloadAndRemoveFromAnExecutingRequestAreRejected to add a real, owned, non-opted-in registration: the branch evaluates a registration's per-registration opt-in (and therefore its staleness) before the guard runs, so master's fake registration would throw "stale" instead of the expected request-callback rejection. Threaded the explicit caller context through the branch's lifecycle tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
marcschier
deleted the
marcschier/4149-remove-asynclocal-requestmanager
branch
August 10, 2026 09:29
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.
Description
RequestManagertracked whether the calling flow was serving a Client request in aprivate readonly AsyncLocal<bool> m_inServiceDispatch. It was set by aStandardServer.ProcessRequestAsyncoverride and read byNodeManagerLifecycle, so that a lifecycle operation (AddAsync/ReloadAsync/RemoveAsync) started from inside a request fails fast withInvalidOperationExceptioninstead of draining — and therefore waiting for — its own request.Two problems with that mechanism, raised in review of #4093:
AsyncLocal<T>written inside anasyncmethod never flows back to the caller that awaited it, so every scope had to chain to the previous value by hand and restore it on dispose. Subtle, and easy to get wrong when a new entry point is added.RequestManageralso had no business carrying ambient state on behalf of a different subsystem; its job is tracking requests for the drain.What changed
The guard is now an exact identity lookup against the request registry
RequestManageralready maintains, and the operation is threaded explicitly:RequestManager—internal bool IsExecutingRequest(IOperationContext?)performs a reference-identity lookup againstm_requestsunder the existingm_requestsLock.m_inServiceDispatch,EnterServiceDispatchScope(), the nestedServiceDispatchScopeclass and the flag-basedIsExecutingRequestproperty are gone.StandardServer— theProcessRequestAsyncoverride is removed; it existed only to open the dispatch scope.INodeManagerLifecycle— every member (AddAsync×2 factory kinds,ReloadAsync×2,RemoveAsync) now takes the caller'sIOperationContext?directly, ahead ofCancellationToken ct = default. No overloads and no parallel signature are kept: the interface is new in 2.0 and outside the 1.5.378 compatibility rule, so call sites state their context explicitly or passnull.HostedNodeManagerLifecycleandRuntimeNodeSetLifecycleExtensionsthread the argument through;AddRuntimeNodeSetAsync/ReloadRuntimeNodeSetAsyncremain single methods.SystemContextOperationExtensions.GetOperationContext(this ISystemContext)(new,Opc.Ua.Core) lets a NodeManager or Method callback hand over the operation it already received without downcasting. It switches onSessionSystemContextandSystemContext, the two independent roots every context derives from. No new interface is introduced: casting the context toIOperationContextwould not do, because a context implements that by delegating to the operation it was created for, so the cast yields the context rather than the operation.NodeManagerLifecycle.EnsureNotRequestCallbackno longer consultsServerState. The previousCurrentState == Runningpredicate could let a genuinely re-entrant call slip past while the server was shutting down; the request registry answers exactly in every state.A NodeManager or Method callback now reads:
and a control-plane caller (hosted service, DI consumer) passes
null.Behaviour
Unchanged for the case the guard exists to catch: a lifecycle call made on behalf of an executing request is still rejected up front with the same
InvalidOperationExceptionand the same message.Two deliberate differences:
OperationContextthat was never enrolled as a Client request is now correctly allowed through. The boolean flag could not distinguish it from a real request.RequestManager.RequestDrainTimeout, which already covered any request that bypassed the service pipeline. Automatic detection for an uncooperative caller inherently requires ambient state, which is exactly what this change removes. Documented indocs/NodeManagers.md.Alternatives considered and rejected
RequestDrainTimeoutCancellationTokenit passesMasterNodeManagerActivity.Current/ThreadLocalTesting
RequestManagerTests— the threeEnterServiceDispatchScopetests are replaced by coverage forIsExecutingRequest(context): null, never-registered, only-while-scope-open, visible across anawaitand a backgroundTask.Run, and two concurrently executing requests not confusing each other.NodeManagerLifecycleTests— rejection from an executing request for Add / Reload / Remove without invoking the factory, acceptance for a context that is not an executing request, acceptance once the request has completed, and the full callback shape (ServerSystemContext.Copy(operation)→GetOperationContext()→ rejected).HostedNodeManagerLifecycleTests— forwarding tests for the caller-context argument on all five members.tests/Opc.Ua.Core.Tests/Stack/State/SystemContextOperationExtensionsTests.cs(new) —GetOperationContextacross both context roots and the null-argument guard.Related Issues
Checklist