Skip to content

[Server] Add live NodeManager shadow and immediate reload - #4147

Open
marcschier wants to merge 12 commits into
marcschier/wot-04-generator-wotfrom
marcschier/wot-05-lifecycle
Open

[Server] Add live NodeManager shadow and immediate reload#4147
marcschier wants to merge 12 commits into
marcschier/wot-04-generator-wotfrom
marcschier/wot-05-lifecycle

Conversation

@marcschier

Copy link
Copy Markdown
Collaborator

Summary

Adds PR 5 of the stack splitting #4093 into independently reviewable pieces, targeting master directly.

This PR extends the NodeManager lifecycle with live shadow reload support and RuntimeNodeSet lifecycle helpers. ShadowReloadAsync stages a replacement generation and routes new service requests to it while monitored items already owned by the retired generation continue there until subscriptions drain.

ImmediateReloadAsync keeps the immediate policy but implements it on the existing detach machinery at the maintainer's request: affected non-durable monitored items are detached/marked deleted with IDetachableMonitoredItem, queue BadNodeIdUnknown, and are not migrated to the replacement generation.

The prior NodeManager generation is disposed before ImmediateReloadAsync returns in the covered lifecycle path. The bespoke permanent retirement-error path is intentionally removed, so follow-up operations use the existing detached-item semantics rather than a separate retirement state.

Validation

  • dotnet build src\Opc.Ua.Server\Opc.Ua.Server.csproj -c Release -f net10.0 -v:m
  • dotnet build tests\Opc.Ua.Server.Tests\Opc.Ua.Server.Tests.csproj -c Release -p:CustomTestTarget=net10.0 -v:m
  • dotnet test tests\Opc.Ua.Server.Tests\Opc.Ua.Server.Tests.csproj -c Release -p:CustomTestTarget=net10.0 --no-build — 3914 passed, 9 skipped, 0 failed
  • dotnet build tests\Opc.Ua.Server.Tests\Opc.Ua.Server.Tests.csproj -c Release -p:CustomTestTarget=net48 -v:m
  • dotnet test tests\Opc.Ua.Server.Tests\Opc.Ua.Server.Tests.csproj -c Release -p:CustomTestTarget=net48 --no-build — full run hit local durable queue file-lock flake; the failed test passed in isolation. Full run otherwise reported 3913 passed, 9 skipped, 1 failed.

Extends the NodeManager lifecycle already in master with ShadowReloadAsync, which stages a replacement generation and routes new service requests to it while existing monitored items continue against the retired generation until subscriptions drain.

ImmediateReloadAsync is the alternative policy for callers that cannot wait for a graceful drain. It detaches the affected non-durable monitored items through the existing detach path, so they report BadNodeIdUnknown, and does not park them for later recovery, since the generation they belonged to is gone.

The existing fail-closed ReloadAsync and RemoveAsync behaviour is unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
Copilot AI review requested due to automatic review settings July 31, 2026 10:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Adds shadow-reload and immediate-reload support to the NodeManager lifecycle, including request-drain coordination so existing monitored items can continue to be serviced by a retired generation while new requests route to a replacement generation.

Changes:

  • Introduces ShadowReloadAsync / ImmediateReloadAsync lifecycle paths and runtime NodeSet extension helpers.
  • Adds request-lifecycle coordination + shutdown sequencing updates to drain/admit requests safely during lifecycle operations.
  • Expands test coverage for runtime NodeSet configuration hooks, shadow reload behavior, and monitored-item transfer rollback.

Reviewed changes

Copilot reviewed 35 out of 38 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
tests/Opc.Ua.Server.Tests/SubscriptionTests.cs Updates mocks to match new monitored-item transfer signature.
tests/Opc.Ua.Server.Tests/RuntimeNodeSet/RuntimeNodeSetLifecycleTests.cs Adds an end-to-end shadow reload test covering routing vs. existing monitored items.
tests/Opc.Ua.Server.Tests/RuntimeNodeSet/RuntimeNodeSetLifecycleExtensionsTests.cs New unit tests for runtime NodeSet lifecycle extension method argument contracts and wiring.
tests/Opc.Ua.Server.Tests/RuntimeNodeSet/RuntimeNodeSetConfigureAsyncTests.cs New tests for RuntimeNodeSetOptions.ConfigureAsync ordering + generation-owner disposal semantics.
tests/Opc.Ua.Server.Tests/Opc.Ua.Server.Tests.csproj Removes WotCon server project reference.
tests/Opc.Ua.Server.Tests/NodeManager/*Tests.cs Updates/extends adapter delegation + rollback coverage for monitored-item transfers.
tests/Opc.Ua.Server.Tests/Hosting/HostedNodeManagerLifecycleTests.cs Adds ShadowReloadAsync delegation/guard tests.
tests/Opc.Ua.Server.Tests/Hosting/CombinedHostingTests.cs Removes WotCon hosting test.
src/Opc.Ua.Server/Subscription/SubscriptionManager.cs Adds internal test hooks around condition refresh worker shutdown/reset.
src/Opc.Ua.Server/Server/StandardServer.cs Refactors shutdown/disposal orchestration, adds deferred shutdown observer + new lifecycle registration.
src/Opc.Ua.Server/Server/RequestManager.cs Adds lifecycle admission/drain coordination and waiter exclusion support.
src/Opc.Ua.Server/Server/RequestManagerLifecycleExtension.cs New request lifecycle coordination helper for shutdown/lifecycle waits.
src/Opc.Ua.Server/RuntimeNodeSet/* Adds ConfigureAsync, lifecycle-from-request option, and shadow/immediate reload extension methods.
src/Opc.Ua.Server/NodeManager/MasterNodeManager.cs Adds monitored-item transfer transaction/rollback and ownership-sensitive dispatch to support shadow-retired generations.
src/Opc.Ua.Server/NodeManager/Lifecycle/* Adds routing table Contains, lifecycle interface surface for shadow/immediate reload, and a committed-reload exception type.
src/Opc.Ua.Server/NodeManager/NodeManager.cs Adds transfer options + rollback hooks to sync/async node managers and adapters.
samples/Quickstarts.Servers/SampleNodeManager/SampleNodeManager.cs Updates sample node manager transfer handling to honor new transfer options and rollback hook.
Suppressed comments (1)

tests/Opc.Ua.Server.Tests/Opc.Ua.Server.Tests.csproj:1

  • This PR removes the WotCon server project reference (and a related hosting test elsewhere), but the PR description focuses on NodeManager shadow/immediate reload and runtime NodeSet helpers. Either update the PR description to explicitly call out the WotCon test/reference removal, or move that change into a separate PR to keep scope aligned.

Comment thread src/Opc.Ua.Server/Server/RequestManagerLifecycleExtension.cs Outdated
Comment thread src/Opc.Ua.Server/NodeManager/MasterNodeManager.cs Outdated
Comment thread src/Opc.Ua.Server/NodeManager/MasterNodeManager.cs Outdated
…nager

NodeStateLookupExtensions.FindByDataType has no call site in this change; its
consumers are the fluent NodeManager builder and the generated node manager
templates, so it moves to the source generation PR that introduces them.

Adds the RequestManager admission and drain tests and the StandardServer
shutdown coverage that belong with the shutdown work already in this PR.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
@marcschier

Copy link
Copy Markdown
Collaborator Author

Pushed commit 0382d0a with the two requested corrections:

  • Reverted NodeStateLookupExtensions.cs to origin/master; git grep -n "FindByDataType" is clean.
  • Restored the RequestManager admission/drain tests and StandardServer shutdown coverage. The imported files did not reference the dropped retirement APIs, so no retirement tests were removed.

Validation: net10.0 server build passed, net10.0 server tests passed (3927 passed / 9 skipped). net48 server tests built and full-suite run had only the known DurableDataValueQueueVerifyReferenceBatchingAsync local file-lock flake; rerun in isolation passed (1 passed / 1 skipped).

Shutdown completion tracking used a linear scan over a list for every node
manager, making a shutdown pass quadratic in the number of managers. It now uses
a set keyed by reference identity, reusing the existing RefEqualityComparer.

Grouping monitored items by owning node manager did the same linear search per
item. An index keyed by reference identity makes it linear overall while the
returned list keeps its first-encounter ordering, which callers depend on.

Also fills in the empty exception documentation on the runtime node set factory
helpers and corrects the copyright year on RequestManagerLifecycleExtension.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
@marcschier

Copy link
Copy Markdown
Collaborator Author

Flagging a genuine regression on this branch that I have not yet fixed, with the evidence so far.

Symptom

NodeManagerLifecycleNamespaceRefreshTests.LiveNodeManagerAddRefreshesTheClientNamespaceTable
fails with System.TimeoutException after 60s. The test waits for a ModelChanged event with
NamespaceTableRefreshed == true following AddRuntimeNodeSetAsync; the event never arrives.

It is not a flake, and not pre-existing

Run Result
origin/master (f0ab787), full suite, net10.0 3889 passed, 0 failed
this branch merged with master, full suite 3982 passed, 1 failed
repeated locally same failure, identical
Azure DevOps build 16182 same failure, identical counts
the same test in isolation passes in 8s

Three reproductions, and the master baseline is clean - so this is caused by this PR, not by the
environment and not by an existing defect.

It is the code, not the added tests

This PR adds only two test files (RuntimeNodeSet/RuntimeNodeSetConfigureAsyncTests.cs and
RuntimeNodeSet/RuntimeNodeSetLifecycleExtensionsTests.cs). Excluding both still reproduces the
failure (3961 passed, 1 failed), so the added tests are not poisoning it.

Running only Opc.Ua.Server.Tests.NodeManager (314 tests, including the failing one) passes, so
something in the wider suite leaves state that this branch is sensitive to and master is not. The
fixture is [NonParallelizable], so this is ordering/shared state rather than parallel contention.

Why local validation missed it

The test was added to master by 636912b (#4115, "refresh the client namespace table on model
change"), which post-dates this branch's base. Validating the branch standalone never ran it.

This generalises to the whole stack: PR CI builds refs/pull/N/merge, i.e. head merged with
base, so every branch must be validated merged with current master, not standalone. I have now
merged master into this branch (21b22e9) so CI and local runs agree.

Where I would look next

This PR modifies ModelChangeTracker.cs and IModelChangeTracker.cs, and #4115 modified the same
files to add the namespace-table refresh. The merge succeeded textually with no conflict, which is
exactly the situation where a semantic conflict hides. The refresher wiring does survive the merge
(ModelChangeTracker still takes an INamespaceTableRefresher, and both Session and
ManagedSession still implement it), so the fault is more subtle than a dropped interface - most
likely the refresh path is conditional on state that an earlier test leaves behind.

Not resolving this comment; it is a real blocker for merging this PR.

@marcschier

Copy link
Copy Markdown
Collaborator Author

Correcting and sharpening my earlier analysis of the
LiveNodeManagerAddRefreshesTheClientNamespaceTable failure. One claim I made was too strong.

What I got wrong. I said "it is the code, not the added tests". That inference was based on
excluding this PR's two new test files, but the PR also adds tests inside modified files - even
with those two excluded the run still had 77 more tests than master, so extra load had not actually
been ruled out. The claim was not justified by that experiment.

What has since been established. I re-ran the full suite with
NUnit.NumberOfTestWorkers=1, which removes parallel contention entirely. It still fails, with the
identical signature (3982 passed, 1 failed, the test taking 1m1s). So the failure is ordering or
accumulated state, not load and not parallel contention
.

Consolidated evidence:

Experiment Result
origin/master f0ab787, full suite 3889 passed, 0 failed
this branch + master, full suite (x2 local, x1 ADO 16182) 3982 passed, 1 failed
the test alone passes, 8s
Opc.Ua.Server.Tests.NodeManager only (314 tests) passes
all 18 remaining [NonParallelizable] fixtures + this test (296 tests) passes
ReferenceServerTest + this test (45 tests) passes
full suite, single worker fails, same signature
full suite minus this PR's two new test files fails

So: some fixture that runs earlier in the full suite leaves state this branch is sensitive to and
master is not. It is not any of the non-parallel fixtures, and not ReferenceServerTest.

What is ruled out. The client side is not involved. #4115, which added both this test and the
namespace-refresh feature, is entirely under src/Opc.Ua.Client/, and this branch's client-side
delta against master is now empty - git diff origin/master HEAD -- src/Opc.Ua.Client/ returns
nothing. So the tracker, INamespaceTableRefresher, and the NamespaceTableRefreshed flag are
byte-identical to master. The fault is on the server side, in whether the model change is emitted
in a form the client recognises as needing a namespace-table refresh.

Still open. I have not identified the specific poisoning predecessor. Bisecting further needs a
deterministic-order run with per-test timing; my attempts to capture a TRX for the ordering did not
produce output, and each full run costs roughly eight minutes.

Leaving this unresolved - it remains a blocker for this PR, and the characterisation above should
make the remaining search short for whoever picks it up.

@marcschier

Copy link
Copy Markdown
Collaborator Author

Note on the second CI failure, so it is not confused with the namespace-refresh regression.

Opc.Ua.Security.Certificates.Tests (net10.0) failed in build 16204 with:

##[error]Test host did not exit cleanly and produced no TRX results.

That is a test-host crash, not an assertion failure - no test actually reported a result. This PR
does not touch that area at all:

git diff --stat origin/master origin/marcschier/wot-05-lifecycle \
    -- src/Opc.Ua.Security.Certificates tests/Opc.Ua.Security.Certificates.Tests

returns nothing. So it should clear on the next run.

The only substantive failure on this PR remains
LiveNodeManagerAddRefreshesTheClientNamespaceTable, analysed in the comments above.

@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 79.31727% with 309 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.91%. Comparing base (8a10139) to head (d3c8030).
⚠️ Report is 5 commits behind head on master.

Files with missing lines Patch % Lines
src/Opc.Ua.Server/NodeManager/MasterNodeManager.cs 81.58% 101 Missing and 29 partials ⚠️
src/Opc.Ua.Server/Server/StandardServer.cs 80.74% 52 Missing and 26 partials ⚠️
...Server/RuntimeNodeSet/RuntimeNodeSetNodeManager.cs 57.44% 17 Missing and 3 partials ⚠️
....Server/Server/RequestManagerLifecycleExtension.cs 77.90% 9 Missing and 10 partials ⚠️
...pc.Ua.Server/NodeManager/AsyncCustomNodeManager.cs 21.05% 15 Missing ⚠️
src/Opc.Ua.Server/NodeManager/CustomNodeManager.cs 16.66% 14 Missing and 1 partial ⚠️
.../Opc.Ua.Server/Subscription/SubscriptionManager.cs 0.00% 10 Missing and 2 partials ⚠️
...ver/NodeManager/Adapters/SyncNodeManagerAdapter.cs 56.25% 7 Missing ⚠️
src/Opc.Ua.Server/Server/RequestManager.cs 94.54% 3 Missing and 3 partials ⚠️
...pc.Ua.Server/Hosting/HostedNodeManagerLifecycle.cs 50.00% 2 Missing ⚠️
... and 4 more
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##           master    #4147      +/-   ##
==========================================
- Coverage   80.23%   79.91%   -0.33%     
==========================================
  Files        1515     1527      +12     
  Lines      209980   212839    +2859     
  Branches    36213    36693     +480     
==========================================
+ Hits       168479   170090    +1611     
- Misses      28867    29983    +1116     
- Partials    12634    12766     +132     
Files with missing lines Coverage Δ
...er/NodeManager/Adapters/AsyncNodeManagerAdapter.cs 97.18% <100.00%> (+0.15%) ⬆️
...r/NodeManager/Lifecycle/IDynamicNodeManagerHost.cs 100.00% <ø> (ø)
...rver/NodeManager/Lifecycle/NodeManagerLifecycle.cs 79.95% <ø> (+4.82%) ⬆️
...untimeNodeSet/RuntimeNodeSetLifecycleExtensions.cs 100.00% <100.00%> (ø)
....Ua.Server/RuntimeNodeSet/RuntimeNodeSetOptions.cs 100.00% <ø> (ø)
src/Opc.Ua.Server/Subscription/Subscription.cs 92.41% <100.00%> (+0.10%) ⬆️
src/Opc.Ua.Server/NodeManager/INodeManager.cs 87.50% <85.71%> (-12.50%) ⬇️
...r/Lifecycle/NodeManagerReloadCommittedException.cs 66.66% <66.66%> (ø)
...RuntimeNodeSet/RuntimeNodeSetNodeManagerFactory.cs 83.69% <90.90%> (+2.27%) ⬆️
...pc.Ua.Server/Hosting/HostedNodeManagerLifecycle.cs 92.59% <50.00%> (-7.41%) ⬇️
... and 10 more

... and 69 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@marcschier

Copy link
Copy Markdown
Collaborator Author

Bisection update on LiveNodeManagerAddRefreshesTheClientNamespaceTable, so this work is not
repeated. No single fixture group reproduces it.

I split Opc.Ua.Server.Tests into disjoint groups and ran each one together with the failing test:

Subset run with the failing test Tests Result
NodeManager namespace 314 pass
all 18 remaining [NonParallelizable] fixtures 296 pass
ReferenceServerTest 45 pass
folders A-L (Alarms...Location) 1164 pass
folders R-S (RateLimiting...Subscription) 189 pass
root fixtures A-M (Aggregate*...MonitoredNode2Tests) 1116 pass
root fixtures N-U (NodeState*...UserManagement*) 713 pass
full suite 3992 fail
full suite, NUnit.NumberOfTestWorkers=1 3992 fail
full suite minus this PR's two new test files 3971 fail

So it is neither parallel contention nor one poisoning predecessor - it only manifests under the
complete ~4000-test run. That points at cumulative state or resource pressure (sockets,
certificate stores, handles) making the 60s wait for the ModelChanged /
NamespaceTableRefreshed event insufficient, rather than a logic error on a specific path.

Worth stressing what this does not change: origin/master runs the same full suite at
3889 passed / 0 failed, so whatever the mechanism, this branch is more sensitive to it than
master is. It remains a real regression to fix, not something to wave through.

Two concrete suggestions for whoever picks this up:

  1. Instrument the server side to log whether the ModelChangeEvent is emitted at all after
    AddRuntimeNodeSetAsync, and whether the client's model-change subscription is active at that
    moment. The failure mode (60s timeout, no event) does not distinguish "never emitted" from
    "emitted before the subscription was ready".
  2. The earlier log line Failed to apply subscription changes; will retry from the client during
    the full run suggests the model-change subscription may not be active when the event fires,
    which would make this a race the full-suite timing exposes rather than a lost notification.

@marcschier
marcschier changed the base branch from master to marcschier/wot-04-generator-wot August 1, 2026 11:54
@marcschier

Copy link
Copy Markdown
Collaborator Author

New decisive finding on LiveNodeManagerAddRefreshesTheClientNamespaceTable, after the stack was
linearised (this branch now sits on top of #4134 and carries the whole lower stack).

It is not slowness — the event never arrives.

I raised only the inner wait from 60s to 105s, staying inside the existing [CancelAfter(120_000)]
budget, and re-ran the full suite. The test then ran for 1m47s and still failed. So the
ModelChanged notification with NamespaceTableRefreshed is never delivered; it is not arriving
late under load.

The captured log narrows it further. Within the test's window:

12:19:49.229 [Opc.Ua.Server.Subscription] Subscription ITEMS CREATED, Id=3860163973, ItemCount=1
   ... 1m44s of silence ...
12:21:33.947 [Opc.Ua.Client.Subscriptions.Subscription] Adjusted KeepAliveCount from 0 to 10

So the model-change subscription is created successfully with its monitored item, and then
nothing is published at all until the wait expires and teardown begins. There is no server-side log
of the namespace add or of a model change being raised in that window either.

Two incidental observations from the same log, both worth a look independently of this bug:

  1. Teardown reports Server shutdown resource disposal failed. One or more base server resources could not be disposed. (Index was out of range. Must be non-negative and less than the size of the collection.) — an unrelated disposal defect.
  2. The fixture's certificate store grows 1 → 9 entries during this single test, each growth
    triggering a full store reload.

Combined with the earlier bisection - no subset of the suite reproduces it, it still fails with
NUnit.NumberOfTestWorkers=1, and the client-side code is byte-identical to master - the evidence
points at the server not emitting the notification once enough prior state has accumulated, rather
than at the client failing to receive it.

Still open; not resolving.

StartTrackingAsync used to return after queueing the pump task, before StreamingSubscription had created the event monitored item on the server. A server-side model change raised in that window is not replayed, so the lifecycle namespace refresh test could lose the GeneralModelChangeEvent under CI load.

Plumb an internal readiness hook through StreamingSubscription and have ModelChangeTracker wait until the event monitored item reports Created, surfacing terminal creation errors instead of weakening the test with sleeps or longer timeouts.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
Comment thread src/Opc.Ua.Server/Server/StandardServer.cs
StandardServer.Dispose() must keep existing synchronous callers working, but it cannot block on the asynchronous shutdown task without violating the repository's sync-over-async ban and risking deadlocks while requests are draining.

Add IAsyncDisposable so callers that need deterministic release can await DisposeAsync(). The async path joins the existing server-internal shutdown machinery, waits for base-resource disposal to finish even when synchronous Dispose races with it, and preserves the deferred observer used by Dispose().

Add disposal lifecycle tests for started, never-started, idempotent, mixed synchronous/asynchronous, and deferred-shutdown paths.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
Comment thread src/Opc.Ua.Server/NodeManager/Lifecycle/INodeManagerLifecycle.cs
Comment thread src/Opc.Ua.Server/NodeManager/AsyncCustomNodeManager.cs
Comment thread src/Opc.Ua.Server/NodeManager/IMasterNodeManager.cs
/// transfer implementations that have not been updated to consume
/// <see cref="MonitoredItemTransferOptions"/>.
/// </summary>
public static class MonitoredItemTransferExecution

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i dont like that this introduces ambient state, just fail the transfer for legacy or make it work wihtout a scope

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed that ambient state is a smell, and I would rather remove it than defend it.

Before I change the behaviour I want to confirm which of your two suggestions you mean, because they are not equivalent:

  • "just fail the transfer for legacy" — a behaviour change: legacy INodeManager implementations that do not flow the scope would get a failed TransferSubscriptions instead of a working one. Clean, but it breaks existing third-party node managers that work today.
  • "make it work without a scope" — keep the behaviour and thread the state explicitly through the call chain instead of ambiently. No breakage, but it touches the INodeManager transfer signatures.

I lean towards the second (explicit parameter, no AsyncLocal) since it removes the ambient state without breaking existing implementers, but it does change a public interface so I would like your call before doing it. Leaving open.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding the costing we already did on this, so the choice can be made on numbers rather than preference. There are three ways to get rid of the ambient scope and they are very different in cost and blast radius:

1. Full removal — flow the context explicitly everywhere. Roughly 1-2 weeks. It breaks INodeManagerLifecycle for third-party implementers, and the main risk is a missed context hand-off silently reintroducing a wait-on-self in the request drain — which is the failure mode the scope exists to prevent.

2. Additive explicit-context overloads. Roughly 2-4 days. New overloads take the context as a parameter, the old ones keep working, and the ambient scope stops being the only path. Non-breaking, and it is the option that actually answers your objection.

3. A single ambient token instead of the current scope. Roughly 0.5-1 day, but it still leaves ambient state, so I do not think it satisfies what you are asking for.

Your other suggestion — "just fail the transfer for legacy" — is cheapest of all, but it is a behaviour change: existing third-party INodeManager implementations that work today would start getting failed TransferSubscriptions. Worth doing only if we are happy to declare those unsupported.

My recommendation is option 2: it removes the ambient-state smell you are objecting to, keeps third parties working, and is days rather than weeks. If you agree I will do that; if you would rather have the full removal in option 1, that is a bigger piece of work and I would want it tracked separately from this PR rather than expanding a 45-file change further.

@marcschier marcschier Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My suggestion: Option 1. @romanett agreed?

public sealed class RuntimeNodeSetNodeManagerFactory : IAsyncNodeManagerFactory
public sealed class RuntimeNodeSetNodeManagerFactory :
IAsyncNodeManagerFactory,
IRequestCallbackSafeNodeManagerFactory

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i dont like that this new interface is introduced, just leave the property on the type

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Happy to drop the interface — I want to check where you want the property to live, because the two options differ in compatibility.

IRequestCallbackSafeNodeManagerFactory is internal and used in exactly one place, as a capability probe: NodeManagerLifecycle.cs:1804 does factory is IRequestCallbackSafeNodeManagerFactory. A test factory also implements it to opt in.

  • Property on the concrete RuntimeNodeSetNodeManagerFactory — then NodeManagerLifecycle has to type-check the concrete class, which couples the lifecycle to one factory implementation and stops any other factory (including third-party ones) from opting in.
  • Property on IAsyncNodeManagerFactory — cleaner to read and no marker interface, but it is a breaking change for every existing implementer unless it gets a default implementation.

If you mean the second and are happy for it to be a breaking addition to IAsyncNodeManagerFactory (or defaulted), say so and I will make that change. Leaving open until then.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CompleteServerDispose(disposeRequest);
}

private ServerDisposeRequest BeginServerDispose()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i also think this overcomplicates stuff, just do the blocking Dispose

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flagging a direct conflict rather than changing this unilaterally, because two maintainers are asking for opposite things on the same code.

@cristipogacean reviewed this same Dispose() and asked for the opposite:

If shutdown must be async, expose IAsyncDisposable/StopAsync and make Dispose either block deterministically or document loudly. As-is, "disposed" no longer means "released".

That is what 1b61a0b9c implemented — StandardServer now implements IAsyncDisposable, DisposeAsync() awaits shutdown and base-resource disposal deterministically, and Dispose() still initiates shutdown but documents that release completes asynchronously. @cristipogacean approved the PR after that change landed.

Blocking Dispose() is also not something I can just switch to:

  1. The repository''s own contributor rules state "DO NOT create SYNC over ASYNC (GetAwaiter().GetResult(), Wait(), Result) unless explicitly requested/confirmed." Making Dispose() block requires exactly that on the shutdown task.
  2. The shutdown waits for admitted requests to drain. Blocking a caller thread on it risks deadlock when Dispose() is called from a thread that the drain depends on — for example from a request-handling or callback context.

So this needs a decision between the two of you rather than me picking a side:

  • Keep the current shapeDisposeAsync() for deterministic release, Dispose() documented as initiate-only.
  • Blocking Dispose() — I can do it, but it needs an explicit sign-off on the sync-over-async exception, and I would want to address the deadlock path.
  • Both — keep DisposeAsync() and have Dispose() block on it; still needs the same exception.

Leaving this thread open pending that call. The same question applies to your comment on #4150 (ServerInternalData.cs:156), which asks for .GetAwaiter().GetResult() explicitly.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replied on #4150. Follow my direction on the feedback thread there.

}
}

private async Task JoinActiveServerInternalShutdownFromRequest(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this shutdown is way to complicated

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taking this together with your Dispose() comment, since the complexity here is mostly the cost of the deterministic-shutdown contract @cristipogacean asked for.

Most of the machinery exists to guarantee three things at once: request admission closes, already-admitted requests drain before resources are torn down, and disposal happens exactly once no matter which of Dispose() / DisposeAsync() / StopAsync arrives first or concurrently. Dropping any one of those simplifies the code substantially.

If the team is willing to relax the drain guarantee — that is, accept that a shutdown may tear down while requests are still in flight — the shutdown collapses to something much smaller. That is a product decision rather than a refactor, so I would rather have it made explicitly than quietly simplify and change shutdown semantics.

Tell me which guarantee you are willing to give up and I will simplify accordingly. Leaving open.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, drain guarantees before shutdown need to remain. Investigate how to make the code more comprehensible and easy to maintain.

/// <summary>
/// Optional request lifecycle coordination used by server shutdown and NodeManager lifecycle
/// operations.
/// </summary>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this adds so much stuff no one understands and probably does not need, needs to be reworked

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair challenge, and I would rather cut this than leave something no one can follow.

RequestManagerLifecycleExtension exists to hold the request-admission and drain state that the orderly shutdown depends on — closing admission, tracking admitted requests, and signalling when the last one has drained so teardown can proceed. It is the mechanism behind the "disposed means released" guarantee @cristipogacean asked for on Dispose().

So its fate is tied to the shutdown-complexity thread above: if the drain guarantee stays, something has to own this state and I would rather improve its naming, documentation and test coverage than delete it; if the team accepts teardown while requests are in flight, most of it can go.

Rather than rework it blind, tell me which way the drain guarantee goes and I will either simplify it away or make it comprehensible — concretely: a short design note in docs/, clearer names, and tests that show the admission/drain transitions. Leaving open.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make it more comprehensible, but leave as is

Explain the three live reload modes and their client-visible tradeoffs. Clarify when monitored-item transfer rollback is used, and enable a deterministic rollback test that exercises a non-no-op owner.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
The type summary described a hand-over between the current and replacement
NodeManagers during a lifecycle transition, but the interface prepares a
monitored item move from a source session to a destination session for
TransferSubscriptions. Describe what it actually coordinates.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants