Fix hosted-server DI composition gaps (#4456)

# Description

Complete the remaining hosted-server DI hooks so applications can
compose the regular server without subclassing `StandardServer` solely
for these extension points.

- Add `ConfigureServerProperties` and ordered, per-server
`ConfigureResources` callbacks. BuildInfo defaults come from the
effective application configuration; explicit property registrations
take precedence without mutating shared options.
- Add authenticator instance/validator-aware factory overloads and
generic/async-delegate startup tasks. Startup tasks are awaited in
registration order, and failures or cancellation clean up the server.
Host shutdown waits for execution cleanup before disposing injected
dependencies, including when its shutdown deadline expires.
- Add asynchronous and legacy node-manager factory instances plus
deferred, configuration-aware `ArrayOf<IAsyncNodeManagerFactory>`
registrations. Resolve factories after configuration and certificate
setup while preserving ownership and feature isolation.
- Register alias stores before address-space creation and add opt-in
standard `TagVariables`/`Topics` materialization. Existing
custom-category behavior is retained; browse nodes are startup snapshots
while queries and supported LastChange values remain live.
- Derive the default DI server from `ReverseConnectServer`, retaining DI
roles/session hooks and authoritative XML/stream configuration. Ignore
queued callbacks from stopped or superseded timers to prevent
reconnection or rearming after shutdown.
- Fix resource translation lookup/formatting exposed by the new
callbacks, retain existing constructor signatures and
direct/custom-server paths, and document the optional hosting APIs.

Adopting these hooks is optional; existing construction and
subclass-based customization remain supported. The migration guide is
unchanged.

No changes to UA-.NETStandard-Samples and no new dependencies.

## Related Issues

- Fixes #4410

## Validation

- Focused server hosting, aliases, reverse-connect, configuration, and
resource suites: **571 passed on net10.0 and 571 passed on net48**.
- Existing affected real-session alias integration fixtures: **11 passed
on each framework**.
- Real reverse-connect discovery, session creation, and BuildInfo reads
verified for options, XML-file, and stream configuration.
- Published and executed the Windows NativeAOT test binary: **7
HostingAotTests passed**.
- Final server-library build passed across all configured target
frameworks with **0 warnings and 0 errors**; no new diagnostics in
changed test files.
- Focused changed-line coverage: **336/338 executable production lines
(99.41%)**.
- Deterministic timer regressions failed before the shutdown fix and
pass afterward.
- CI shutdown follow-up: the unchanged
`UnavailableUpstreamEndpointFailsMappedReadAsync` scenario passes
locally, and **245 hosting tests pass on each of net10.0 and net48**,
including regressions for an expired host shutdown deadline and
already-canceled startup. The local net48 run uses a short NUnit work
directory to avoid Windows path-length limits.

## Azure build 18136 follow-up

- Await WoT browse reconciliation before upload, default-version, and
enable/disable handlers return. Three barrier-controlled regressions
reproduce the exact missing version metadata before the fix.
- Make the reconciliation test observe the actual corrective state frame
rather than require a new acknowledgement after a no-op reseed. The
corrected tests also verify value, status, timestamp, and quiescence;
production gossip behavior is unchanged.
- Drain redirected stdout/stderr when sample processes exit, replacing a
fixed grace delay that could lose the final success line. A controlled
real-process regression fails before the fix.
- Fix CA1508, CA1823, CA1850, CA2012, and CA1861 at their sources, and
correct unsupported legacy AOT declarations and the existing
stress-placeholder target. Modern test targets and AOT analysis remain
enabled.

Follow-up validation:

- Full **Release `UA.slnx` builds pass on net10.0 and net48 with 0
warnings and 0 errors**.
- Complete previously failing WoT net48 job: **1496 passed**. The
identical binary was run from a short local assembly path to avoid the
nested repair worktree exceeding Windows path-length limits.
- Complete previously failing Redundancy.Server net10.0 job: **783
passed**.
- All short-haul HA/process cases: **12 passed**, including both
original identity-failover topologies.
- Affected WoT lifecycle selection: **113 passed on Release net10.0**;
controlled replication selection: **15 passed on each framework**.
- No test assertions, timeouts, coverage thresholds, collector settings,
or CI failure gates were weakened. Existing legacy empty-project result
notices and auto-property coverage exclusions remain unchanged.

These are full solution builds plus the reported test scopes, not a full
local `UA.slnx` test run.

Remote validation at `77c1055f1adb1557c5bf99b70e76f4a0cd95ee94`: **all
265 PR checks passed**, including Azure build **18172**, GitHub Build
and Test, CodeQL, and Docker image checks.

## Checklist

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [x] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [x] I have addressed **all** PR feedback received.

---------

Co-authored-by: romanett <romanett98@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Marc Schier
2026-09-10 05:56:52 +02:00
committed by GitHub
parent f5ad124891
commit 0e600c0f0b
40 changed files with 5125 additions and 220 deletions
+54 -16
View File
@@ -34,8 +34,9 @@ The server library exposes a pluggable backend (`IAliasNameStore`) plus
a default in-memory implementation. Apps assemble their alias inventory
inside a store, then either:
1. Register the store directly with the server-wide
`IAliasNameStoreRegistry` so the standard well-known
1. Register the store through `AddAliasNameStore(...)` on the DI server
builder, or directly with the server-wide `IAliasNameStoreRegistry`,
so the standard well-known
`Aliases`/`TagVariables`/`Topics` nodes start dispatching through it,
**or**
2. Wrap the store in an `AliasNameNodeManager` to expose application-
@@ -47,32 +48,55 @@ Both approaches can be combined.
### Quick start — serving standard categories
```csharp
using Microsoft.Extensions.DependencyInjection;
using Opc.Ua;
using Opc.Ua.Server;
using Opc.Ua.Server.AliasNames;
// inside CreateMasterNodeManager(IServerInternal server, ...):
var tagVariables = new AliasNameCategoryDescriptor(
ObjectIds.TagVariables,
QualifiedName.From(BrowseNames.TagVariables),
AliasNameCapabilities.FindAliasVerbose);
var store = new InMemoryAliasNameStore([tagVariables]);
store.Seed(ObjectIds.TagVariables, "TIC101_Setpoint",
new ExpandedNodeId("Scalar_Static_Double", refServerNs),
store.Seed(ObjectIds.TagVariables, "ServerCurrentTime",
new ExpandedNodeId(VariableIds.Server_ServerStatus_CurrentTime),
serverUri: null,
referenceTypeId: ReferenceTypeIds.AliasFor);
// ... seed more entries ...
((IAliasNameStoreRegistryProvider)server)
.AliasNameStoreRegistry.Register(store);
services.AddOpcUa()
.AddServer(options =>
{
options.ApplicationName = "AliasServer";
options.EndpointUrls.Add("opc.tcp://localhost:4840/AliasServer");
})
.AddAliasNameStore(store)
.ConfigureAliasNames(options => options.MaterializeAliasNodes = true);
```
`AddAliasNameStore(...)` and `AddAliasNameStoreRegistry(...)` register
their stores before address-space startup. The opt-in
`ConfigureAliasNames(Action<AliasNameServerOptions>)` extension is on
`IOpcUaServerBuilder` in `Microsoft.Extensions.DependencyInjection`.
`AliasNameServerOptions` is in `Opc.Ua.Server.AliasNames`, and its
`MaterializeAliasNodes` property defaults to `false`.
When enabled, the normal `ConfigurationNodeManager` materializes
registered standard-category aliases and their declared optional
capabilities after the standard nodes have loaded. No server or
diagnostics-node-manager subclass is needed. The same setting applies
to stores for `Topics`; their targets must be `PublishedDataSetType`
instances. Omit `ConfigureAliasNames(...)` when only method-based
lookup is needed.
When a client calls `Aliases.FindAlias` (`i=23476`),
`TagVariables.FindAlias` (`i=23485`) or `Topics.FindAlias` (`i=23494`),
`DiagnosticsNodeManager`'s late binder routes the call through the
registry to the matching store.
Direct `IAliasNameStoreRegistry` registration remains available for
custom hosting. Register stores before the materialization pass when
their aliases must also be browsable.
### Quick start — application-defined categories
`AliasNameNodeManager` is a `CustomNodeManager2` that owns a namespace
@@ -96,7 +120,8 @@ Options:
NodeId must lie in this namespace; descriptors pointing anywhere else
are skipped with a warning rather than claiming another manager's
ids.
* `MaterializeAliasNodes` (default `true`) — creates one browsable
* `MaterializeAliasNodes` (default `true`, unchanged for
`AliasNameNodeManagerOptions`) — creates one browsable
`AliasNameType` node per alias, with `AliasFor` references to its
targets, exactly as the standard-node materialization below does.
Disable to expose only the category tree.
@@ -127,6 +152,15 @@ the standard well-known nodes — run the same shared walker
(`AliasNameNodeMaterializer`), so they produce structurally identical
Part 17 trees and every fix lands in both at once.
For the standard DI server, `ConfigureAliasNames(options =>
options.MaterializeAliasNodes = true)` makes the normal
`ConfigurationNodeManager` invoke that helper. Its default is `false`,
independent of the custom `AliasNameNodeManagerOptions` default of
`true`. Metadata, resource, and alias configuration use the
`DependencyInjectionStandardServer` hooks; a non-DI custom
`AddServer<TServer>()` rejects these DI-only settings rather than
silently ignoring them.
`DiagnosticsNodeManager.MaterializeRegisteredAliasNameNodesAsync` does
that for every registered store: one `AliasNameType` instance per alias
— BrowseName carrying the alias name, `AliasFor` references to its
@@ -143,8 +177,9 @@ namespace; Part 17 clients compare alias names ignoring the namespace,
so this is transparent to them. The pass is idempotent, and only
creates category nodes whose NodeId lies in the diagnostics namespace —
a descriptor pointing anywhere else is skipped with a warning rather
than claiming another manager's (or the standard NodeSet's) ids. Call
it from an overridden `CreateAddressSpaceAsync`, after `base` has
than claiming another manager's (or the standard NodeSet's) ids.
Custom diagnostics-node-manager implementations can still call the
helper from an overridden `CreateAddressSpaceAsync`, after `base` has
loaded the standard categories:
```csharp
@@ -160,7 +195,7 @@ public override async ValueTask CreateAddressSpaceAsync(
}
```
The same pass also instantiates the **optional Part 17 methods** —
The same pass also instantiates the **optional Part 17 members** —
`FindAliasVerbose`, `AddAliasesToCategory`, `DeleteAliasesFromCategory`
and `LastChange` — on every category whose descriptor declares them
through `AliasNameCapabilities`, including the well-known ones the
@@ -176,9 +211,10 @@ var tagVariables = new AliasNameCategoryDescriptor(
Mutation calls are gated on a `SecurityAdmin` caller over a
`SignAndEncrypt` channel and return `BadUserAccessDenied` otherwise.
It is opt-in: servers that only need `FindAlias` to answer from their
store pay nothing. The created nodes are a snapshot taken at
address-space creation — aliases added or removed later through
Standard-category materialization is opt-in: servers that only need
`FindAlias` to answer from their store do not create alias instance
nodes. The created nodes are a snapshot taken at address-space creation
— aliases added or removed later through
`AddAliasesToCategory` / `DeleteAliasesFromCategory` change what
`FindAlias` returns and advance `LastChange`, but do not add or remove
`AliasNameType` nodes.
@@ -484,6 +520,8 @@ while letting PubSub drive the rest.
* OPC UA Part 17 specification:
https://reference.opcfoundation.org/v105/Core/docs/Part17/
* [Dependency Injection](DependencyInjection.md#alias-name-stores-and-standard-browse-nodes)
— hosted store registration and standard-category materialization.
* `tools/Opc.Ua.SourceGeneration.Core/Design/StandardTypes.xml` —
Part 17 type definitions consumed by the source generator.
* `tests/Opc.Ua.Server.Tests/AliasNames/` — server-side unit tests.
+235 -17
View File
@@ -75,6 +75,7 @@ Identity-provider extensions hang off `IOpcUaServerBuilder`,
|-----------------------------------------------|--------------------------|----------------------------------|
| `AddDefaultIdentityAuthenticators(...)` | server, gds | `OpcUa:Server:Identity:Defaults` |
| `AddIdentityAuthenticator<T>()` | server, gds | — |
| `AddIdentityAuthenticator(instance)` / `(factory)` | server | — |
| `AddIdentityAugmenter<T>()` | server, gds | — |
| `AddGdsApplicationSelfAdminProvider()` | gds | — |
| `AddJwtIssuer(...)` | server, gds | `OpcUa:Server:Identity:Issuers[]`|
@@ -318,10 +319,13 @@ from the dependency injection surface.
## Server feature
`builder.AddServer(o => …)` registers an OPC UA `StandardServer` as an
`IHostedService` via a private `OpcUaServerHostedService`. Endpoints,
PKI root, security policies, and the application instance certificate
are all set up on host startup.
`builder.AddServer(o => …)` hosts a `DependencyInjectionStandardServer`
via a private `OpcUaServerHostedService`. This implementation derives
from `ReverseConnectServer`, so configured reverse connections work
alongside the dependency-injection hooks without an application server
subclass. The ordinary `StandardServer` and its direct-construction
behavior are unchanged. Endpoints, PKI root, security policies, and the
application instance certificate are all set up on host startup.
```csharp
using Microsoft.Extensions.DependencyInjection;
@@ -347,8 +351,9 @@ builder.Services
await builder.Build().RunAsync();
```
`.AddNodeManager<T>()` and `.AddSyncNodeManager<T>()` register the
factory under an `OpcUaServerNodeManagerRegistration` wrapper that is
`.AddNodeManager(...)`, `.AddNodeManagers(...)`, and
`.AddSyncNodeManager<T>()` register factories under an
`OpcUaServerNodeManagerRegistration` wrapper that is
**scoped to the regular server feature**. Node managers registered this
way are **not** visible to the GDS / LDS hosted services running in the
same container. See *Combined hosts* below.
@@ -381,7 +386,7 @@ Advanced server services can be supplied through the same fluent builder:
```csharp
builder.Services
.AddOpcUa()
.AddServer<MyStandardServer>(o => /* … */) // optional StandardServer subclass
.AddServer(o => o.ApplicationName = "MyServer")
.AddSessionManager<MySessionManager>()
.AddSubscriptionManager<MySubscriptionManager>()
.AddDurableSubscriptions(subscriptionStore, monitoredItemQueueFactory)
@@ -397,6 +402,188 @@ an anonymous authenticator matching its default anonymous user-token policy.
If a non-anonymous user-token policy is configured without a corresponding
authenticator, startup logs a warning.
The customization extensions below are methods on `IOpcUaServerBuilder`
in `Microsoft.Extensions.DependencyInjection`; they return the same builder.
They use the existing server and dependency-injection dependencies.
Adopting these hooks is optional. Existing direct server construction and
subclass-based customization remain supported; upgrading does not require
switching to dependency injection.
### Server metadata
`ConfigureServerProperties(Action<Opc.Ua.ServerProperties>)` configures
the metadata published in the server's build information:
```csharp
services.AddOpcUa()
.AddServer(options =>
{
options.ApplicationName = "PlantServer";
options.ProductUri = "urn:example:PlantServer";
})
.ConfigureServerProperties(properties =>
{
properties.ManufacturerName = "Example Automation";
properties.SoftwareVersion = "2.0.0";
properties.BuildNumber = "42";
properties.BuildDate = new DateTime(2026, 9, 1, 0, 0, 0, DateTimeKind.Utc);
});
```
The callback configures `IOptions<Opc.Ua.ServerProperties>`. A directly
registered `Opc.Ua.ServerProperties` service takes precedence over those
options; the two sources are not merged.
| Property left unspecified | Default |
|---------------------------|---------|
| `ProductName` | The effective `ApplicationConfiguration.ApplicationName`. |
| `ProductUri` | The effective `ApplicationConfiguration.ProductUri`. |
| `SoftwareVersion` | The existing stack software-version helper's value. |
| `BuildNumber` | The existing stack build-number helper's value. |
| `ManufacturerName`, `BuildDate` | No publisher identity or build date is inferred; configure these explicitly. |
The version and build-number fallbacks are **stack defaults**, not the
publisher application's version. Supply the publisher's own metadata for
released products. On the XML/stream path, the effective application
identity comes from the loaded configuration, including any
`ConfigureLoadedConfiguration` changes.
### Node-manager factories
Use `AddNodeManager<TFactory>()` for an `IAsyncNodeManagerFactory` resolved
from dependency injection, or the existing `AddSyncNodeManager<TFactory>()`
for an `INodeManagerFactory`. Generic factories are resolved lazily, after
the effective application configuration has been loaded or built.
Already-constructed instances are also supported through
`AddNodeManager(IAsyncNodeManagerFactory)` and
`AddNodeManager(INodeManagerFactory)`.
Configuration-dependent constructors should use
`AddNodeManagers(Func<IServiceProvider, ApplicationConfiguration, ArrayOf<IAsyncNodeManagerFactory>>)`.
The callback receives the effective configuration, including loaded-file
overrides, and can return zero, one, or many factories:
```csharp
using Microsoft.Extensions.DependencyInjection;
services.AddOpcUa()
.AddServer("PlantServer.Config.xml")
.AddNodeManagers((sp, configuration) =>
[
ActivatorUtilities.CreateInstance<PlantNodeManagerFactory>(sp, configuration)
]);
```
Here `PlantNodeManagerFactory` is an application-defined
`IAsyncNodeManagerFactory` whose constructor accepts
`ApplicationConfiguration` and any other injected dependencies. Use `[]`
when no factories are needed, or include more factories in the returned
`ArrayOf<IAsyncNodeManagerFactory>`. The loaded configuration is supplied
explicitly; it is **not** registered as a global `ApplicationConfiguration`
service for constructor injection.
### Post-startup tasks
`AddStartupTask<TTask>()`, where `TTask : class, IServerStartupTask`,
registers an injected task implementing
`OnServerStartedAsync(IServerContext, CancellationToken)`. Alternatively,
`AddStartupTask(Func<IServiceProvider, IServerContext, CancellationToken, ValueTask>)`
registers an asynchronous delegate. `IServerStartupTask` is in
`Opc.Ua.Server.Hosting`.
For example, register an application task, or delegate to an injected
initializer whose `InitializeAsync` returns `ValueTask`:
```csharp
serverBuilder.AddStartupTask<MyStartupTask>();
serverBuilder.AddStartupTask((sp, context, cancellationToken) =>
sp.GetRequiredService<MyInitializer>().InitializeAsync(context, cancellationToken));
```
Type registration is idempotent: registering the same `TTask` again does
not run it twice. Separate delegate registrations are distinct tasks.
Tasks run sequentially in registration order, once after the server has
started. An exception or cancellation aborts hosted startup and triggers
server cleanup; subsequent tasks do not run.
`StopAsync` waits for execution cleanup even after a host shutdown timeout,
so the host does not dispose dependencies that the server is still using.
Slow cleanup can therefore outlast that timeout.
Use injected services for application dependencies and the existing typed
node-manager lookup for server subsystems. For example, inside a task:
```csharp
using System.Linq;
using Opc.Ua;
using Opc.Ua.Server;
IDiagnosticsNodeManager diagnostics =
context.FindNodeManagers<IDiagnosticsNodeManager>().Single();
HistoryServerCapabilitiesState capabilities =
await diagnostics.GetDefaultHistoryCapabilitiesAsync(cancellationToken)
.ConfigureAwait(false);
```
No extra `IServerContext` members or server subclass are needed. Prefer
the historian providers' automatic
[capabilities rollup](HistoricalAccess.md#capability-discovery) when it
already describes the server's history support, rather than manually
overriding the published capabilities in a task.
### Alias-name stores and standard browse nodes
`AddAliasNameStore(store)` and `AddAliasNameStoreRegistry(registry)`
make registered stores available before address-space startup.
`ConfigureAliasNames(Action<AliasNameServerOptions>)` controls whether
the normal `ConfigurationNodeManager` also materializes their aliases
and declared optional capabilities under the standard `TagVariables`
and `Topics` categories:
```csharp
serverBuilder
.AddAliasNameStore(aliasNameStore)
.ConfigureAliasNames(options => options.MaterializeAliasNodes = true);
```
`AliasNameServerOptions` is in `Opc.Ua.Server.AliasNames`; its
`MaterializeAliasNodes` defaults to `false`. Materialized browse nodes
are a startup snapshot, not a live mirror of store mutations.
This setting is independent of `AliasNameNodeManagerOptions` for custom
categories, whose `MaterializeAliasNodes` default remains `true`.
See [Alias Names](AliasNames.md#browsable-alias-nodes) for capabilities,
mutation behavior, and the lower-level materialization helper.
### Localized resources
Use `ConfigureResources(Action<Opc.Ua.Server.ResourceManager>)` or
`ConfigureResources(Action<IServiceProvider, Opc.Ua.Server.ResourceManager>)`
to add translations or replace default status text. The callbacks run
after the base resource manager has loaded the stack's default status
text. In a Generic Host, the service-provider overload can also consume
application configuration:
```csharp
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
serverBuilder
.ConfigureResources(resources =>
resources.Add("DeviceUnavailable", "de-DE", "Das Gerät ist nicht verfügbar."))
.ConfigureResources((sp, resources) =>
{
string text = sp.GetRequiredService<IConfiguration>()["Messages:DeviceUnavailable"]
?? "The device is unavailable.";
resources.Add("DeviceUnavailable", "en-US", text);
});
```
The server creates, retains, and disposes its own resource manager.
These callbacks do not register a live `Opc.Ua.Server.ResourceManager`
service for injection. Resolve supporting services from `sp` and use the
manager passed to the callback.
### Migrating with an existing configuration XML file
Applications that already own a classic OPC UA application configuration
@@ -428,15 +615,19 @@ builder.Services.AddOpcUa().AddServer<MyServer>("MyServer.Config.xml");
On this path the file is authoritative: the `OpcUaServerOptions` knobs
that feed the configuration builder (`ApplicationName`, `EndpointUrls`,
`PkiRoot`, policy toggles, transport quotas, `ConfigureBuilder`, ...)
`PkiRoot`, policy toggles, transport quotas, `ReverseConnect`,
`ConfigureBuilder`, ...)
are not applied, and the file also takes precedence over a shared
application registered with `ConfigureApplication(...)`. Options that
act on the hosted server itself (`Identity`, `ConfigureRateLimits`) and
all fluent registrations (`AddNodeManager`, `ConfigureRoles`,
`AddDefaultIdentityAuthenticators`, ...) keep working. To override
individual file settings from code, pass the optional callback, which
runs after the file is loaded and validated but before certificates are
checked and the server starts:
runtime registrations (`AddNodeManager`, `AddNodeManagers`, startup tasks,
authenticators, metadata, resources, and alias settings) keep working.
Configuration-building shortcuts such as `AddReverseConnect` and
`ConfigureOperationLimits` do not override file settings. To override
individual file settings from code, use `ConfigureLoadedConfiguration`,
also exposed as the optional `AddServer` callback below. It runs after
the file is loaded and validated but before certificates are checked
and the server starts:
```csharp
builder.Services
@@ -580,8 +771,12 @@ properties (bindable from `IConfiguration` or set via the
### Server-side reverse connect
A server can dial back to clients via reverse-hello using
`OpcUaServerOptions.ReverseConnect`. The data binds directly from
A regular DI server can dial back to clients via reverse-hello using
`OpcUaServerOptions.ReverseConnect` or the fluent `AddReverseConnect(...)`
shortcut. Its `ReverseConnectServer` base runs the configured outbound
connections while retaining the other DI customization hooks.
The options projection into `ServerConfiguration.ReverseConnect` is
unchanged. The data binds directly from
`OpcUa:Server:ReverseConnect`:
```jsonc
@@ -620,6 +815,13 @@ services.AddOpcUa().AddServer(o =>
});
```
For a loaded XML file or stream, its `ServerConfiguration.ReverseConnect`
is authoritative; use `ConfigureLoadedConfiguration` to change it rather
than the options-building shortcuts. Directly constructed `StandardServer`
instances do not gain reverse-connect behavior. See
[Reverse Connect](ReverseConnect.md#server-side-dependency-injection)
for the hosted lifecycle and a loaded-configuration example.
### Operation limits
```csharp
@@ -670,6 +872,8 @@ the hosted service falls back to a single `Anonymous` policy.
|---|---|
| `ConfigureRoles(Action<RoleConfigurationOptions>)` / `(IConfiguration)` | Registers `RoleConfigurationOptions` for future role-related tuning. The DTO currently has no configurable members; the extension exists as a stable expansion point. |
| `AddIdentityAuthenticator<TAuth>()` | Registers a single custom `IUserTokenAuthenticator` implementation. The hosted service adds it to `IServerInternal.IdentityRegistry` on startup. |
| `AddIdentityAuthenticator(IUserTokenAuthenticator)` | Registers an already-constructed authenticator instance. |
| `AddIdentityAuthenticator(Func<IServiceProvider, ICertificateValidatorEx?, IUserTokenAuthenticator>)` | Creates an authenticator during startup with injected services and the effective certificate validator, which may be `null`. |
| `AddIdentityAugmenter<TAugmenter>()` | Registers a single custom `IIdentityAugmenter` implementation. The hosted service runs it after accepted authentications. |
| `AddDefaultIdentityAuthenticators(Action<DefaultAuthenticatorOptions>)` / `(IConfiguration)` | Registers the four in-box authenticators (Anonymous, UserNamePassword, X509, Jwt) with toggles per type plus the JWT audience / clock-skew settings. |
| `AddJwtIssuer(Action<JwtIssuerOptions>)` / `(IConfiguration)` | Registers a trusted JWT issuer. Multiple calls coexist; each contributes a `StaticIssuerKeyResolver` and / or `JwksIssuerKeyResolver` keyed by `IssuerUri`. |
@@ -719,6 +923,15 @@ hosted service skips that authenticator if neither is in dependency injection. S
[Role-Based Security](RoleBasedUserManagement.md) for the role-mapping
layer.
The instance and factory overloads complement the existing generic
registration. Factories receive the validator after configuration and
certificate setup; a validator-dependent authenticator should fail
clearly if the nullable validator is absent. Register matching
`UserTokenPolicies` as well — an authenticator does not advertise its
token policy automatically. See
[custom authenticator registrations](IdentityProviders.md#custom-authenticator-registrations)
for an example using the configured certificate stores.
### Fluent shortcuts and one-shot presets
The server hosting surface exposes granular extension points and one-shot presets:
@@ -756,8 +969,13 @@ services.AddOpcUa()
```
Use `AddRoleManager(IRoleManager)` or `AddRoleManager<T>()` to replace the default role manager.
Custom server types that need session, subscription, or durable-subscription DI hooks must derive from
`DependencyInjectionStandardServer`; otherwise startup fails fast instead of ignoring those hooks.
`AddServer<TServer>()` remains available for custom server implementations.
Custom types that need session, subscription, or durable-subscription DI
hooks must derive from `DependencyInjectionStandardServer`. The same
requirement applies to DI-only metadata, resource, and alias settings
configured through `ConfigureServerProperties`, `ConfigureResources`,
and `ConfigureAliasNames`. A non-DI custom server rejects these settings
clearly during startup instead of silently ignoring them.
Fluent node managers can be registered without a factory class:
+56 -1
View File
@@ -107,6 +107,61 @@ enables the configured-default-authenticators bridge:
> `AddDefaultIdentityAuthenticators(opt => …)` and `AddJwtIssuer(opt => …)`
> explicitly.
### Custom authenticator registrations
`AddIdentityAuthenticator<TAuth>()` resolves an authenticator from
dependency injection. Two other extensions on `IOpcUaServerBuilder`,
in `Microsoft.Extensions.DependencyInjection`, support already-created
instances and configuration-aware construction:
| Overload | Use |
|----------|-----|
| `AddIdentityAuthenticator(IUserTokenAuthenticator)` | Register an existing authenticator instance. |
| `AddIdentityAuthenticator(Func<IServiceProvider, ICertificateValidatorEx?, IUserTokenAuthenticator>)` | Construct an authenticator during startup using injected services and the effective certificate validator. |
`IUserTokenAuthenticator` is in `Opc.Ua.Identity`;
`ICertificateValidatorEx` is in `Opc.Ua`.
The factory runs after configuration and certificate setup, including
when configuration is loaded from XML or a stream. Its validator can be
`null`: an authenticator that requires it should reject that case
explicitly instead of bypassing validation.
```csharp
using Microsoft.Extensions.DependencyInjection;
using Opc.Ua;
using Opc.Ua.Server.Hosting;
services.AddOpcUa()
.AddServer(options =>
{
options.ApplicationName = "CertificateServer";
options.ApplicationUri = "urn:example:CertificateServer";
options.UserTokenPolicies.Add(new OpcUaUserTokenPolicy
{
TokenType = UserTokenType.Anonymous
});
options.UserTokenPolicies.Add(new OpcUaUserTokenPolicy
{
TokenType = UserTokenType.Certificate
});
})
.AddIdentityAuthenticator(new Opc.Ua.Server.AnonymousAuthenticator())
.AddIdentityAuthenticator((_, validator) =>
new Opc.Ua.Server.X509Authenticator(
validator ?? throw new InvalidOperationException(
"A certificate validator is required for X.509 user tokens.")));
```
Provision user-certificate trust through the
[certificate manager and stores](CertificateManager.md); no certificate
or secret belongs in this registration code. Register matching
`UserTokenPolicies` as shown: adding an authenticator does not
automatically advertise its token policy. Use the service-provider
argument when the factory also needs application services.
The instance and factory overloads are on `IOpcUaServerBuilder`; the
existing generic registration remains available on the GDS builder.
### Client example
The composite builder API takes the supporting service (an
@@ -188,7 +243,7 @@ is registered.
### GDS example
`IGdsServerBuilder` forwards every identity-related extension to the
`IGdsServerBuilder` forwards the shared identity configuration extensions to the
underlying server builder (`ConfigureRoles`,
`AddIdentityAuthenticator<T>`, `AddIdentityAugmenter<T>`,
`AddDefaultIdentityAuthenticators`, `AddJwtIssuer` — each with both
+71 -5
View File
@@ -5,11 +5,11 @@
The Reverse Connect option consists of the following elements:
* Updated C# Stack that supports the *ReverseHello* message for Client and Server;
* Updated server library which support to
* Create a *ReverseConnectServer* derived from a *StandardServer* class.
* Extended configuration parameters to setup the client location and timeouts.
* an API extension in the *ReverseConnectServer* to programmatically control client connections.
* Updated client library which support to
* Updated server library which supports:
* Server-initiated connections through *ReverseConnectServer*, used automatically by the regular dependency-injection server.
* Extended configuration parameters to set up the client location and timeouts.
* An API extension in the *ReverseConnectServer* to programmatically control client connections.
* Updated client library which supports:
* Configure a client endpoint to accept *ReverseHello* messages using a *ReverseConnectManager*.
* A client API extension to allow applications to register for reverse connections either by callback or by waiting for the *ReverseHello* message for a specific server endpoint and application Uri combination. An optional filter for server Uris or endpoint Urls can be applied to allow multiple clients to use the same endpoint.
* The C# [Console Reference Server](../samples/Reference/ConsoleReferenceServer) with reverse connect support in the configuration xml.
@@ -73,6 +73,72 @@ Pass each returned `ITransportWaitingConnection` to the session factory for the
The synchronous `StartService`, `RegisterWaitingConnection`, and `Dispose` APIs remain as obsolete compatibility wrappers. New code should use `StartServiceAsync`, `RegisterWaitingConnectionAsync`, and `DisposeAsync`; the compatibility wrappers may block a caller thread.
## Server-side dependency injection
The regular `AddServer(...)` host uses
`DependencyInjectionStandardServer`, which derives from
`ReverseConnectServer`. Configured outbound reverse connections start
and stop with the hosted server. Existing DI hooks for node managers,
session and subscription services, identity, and startup tasks coexist
with reverse connect; an application does not need a server subclass.
Queued callbacks from disposed or superseded retry timers are ignored,
including during startup-failure cleanup.
```csharp
using Microsoft.Extensions.DependencyInjection;
using Opc.Ua.Server.Hosting;
services.AddOpcUa()
.AddServer(options =>
{
options.ApplicationName = "PlantServer";
options.EndpointUrls.Add("opc.tcp://localhost:4840/PlantServer");
})
.AddReverseConnect(options =>
{
options.ConnectIntervalMs = 15000;
options.Clients.Add(new ServerReverseConnectClientOptions
{
EndpointUrl = "opc.tcp://client.example.com:65300",
Timeout = 30000,
MaxSessionCount = 1,
Enabled = true
});
});
```
`AddReverseConnect(...)` configures `OpcUaServerOptions.ReverseConnect`.
The projection into `ServerConfiguration.ReverseConnect` is unchanged,
and the same options can be bound from `OpcUa:Server:ReverseConnect`.
When `ConfigurationFile` or `ConfigurationStream` supplies the
application configuration, that document is authoritative. The
`ReverseConnect` options and `AddReverseConnect(...)` shortcut do not
overwrite it. Use `ConfigureLoadedConfiguration` to change loaded
settings; this example retains the file's clients and changes only
their retry interval:
```csharp
services.AddOpcUa().AddServer(options =>
{
options.ConfigurationFile = "PlantServer.Config.xml";
options.ConfigureLoadedConfiguration = configuration =>
{
var reverseConnect = configuration.ServerConfiguration.ReverseConnect
?? throw new InvalidOperationException(
"Configure reverse-connect clients in the XML file.");
reverseConnect.ConnectInterval = 20000;
};
});
```
The ordinary `StandardServer` is unchanged. Applications constructing
servers manually still choose `ReverseConnectServer` explicitly when
they need server-initiated connections; setting reverse-connect
configuration alone does not add that behavior to `StandardServer`.
See [Dependency Injection](DependencyInjection.md#server-side-reverse-connect)
for the complete options shape and other hosted-server customization.
## Dependency-injection lifecycle
`AddClient(...)` registers one singleton `ReverseConnectManager`, an `IReverseConnectConfigurationProvider`, and an internal hosted service. In a .NET Generic Host, the hosted service eagerly opens configured listeners during host startup and closes them during host shutdown. In a plain `ServiceCollection` without a running host, `WaitForConnectionAsync` and `RegisterWaitingConnectionAsync` call `EnsureStartedAsync` lazily. Resolving the manager itself never blocks on listener startup.
@@ -26,7 +26,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<IsAotCompatible>true</IsAotCompatible>
<IsAotCompatible Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">true</IsAotCompatible>
<!-- ICaptureSource.StartAsync itself is intentionally NOT annotated, because most
capture sources do not need SharpPcap's dynamic-loading requirement.
NicCaptureSource.StartAsync keeps [RequiresDynamicCode]/[RequiresUnreferencedCode]
@@ -17,7 +17,7 @@
<PackageReadmeFile>NugetREADME.md</PackageReadmeFile>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<Nullable>enable</Nullable>
<IsAotCompatible>true</IsAotCompatible>
<IsAotCompatible Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">true</IsAotCompatible>
<EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
@@ -230,6 +230,10 @@ namespace Opc.Ua.Redundancy.Server
/// <summary>
/// Waits until inbound apply advances beyond <paramref name="observedCount"/>.
/// </summary>
/// <remarks>
/// This observes received state frames, not acknowledgements of outgoing snapshots.
/// A converged peer does not rebroadcast a no-op merge, so sending a snapshot need not advance this count.
/// </remarks>
/// <param name="observedCount">The inbound apply count already observed by the caller.</param>
/// <returns>
/// A task that completes when a later inbound apply has completed, or immediately when it already did.
@@ -0,0 +1,49 @@
/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
namespace Opc.Ua.Server.AliasNames
{
/// <summary>
/// Configures the standard server's OPC UA Part 17 alias-name address space.
/// </summary>
public sealed class AliasNameServerOptions
{
/// <summary>
/// Whether the configuration node manager creates browsable alias and category
/// nodes from the server's registered alias stores. Defaults to <see langword="false"/>,
/// leaving aliases available through the standard query methods only.
/// </summary>
/// <remarks>
/// Stores and aliases must be registered before address-space creation. Materialized
/// nodes are a startup snapshot; later store mutations update query results but do
/// not add or remove browsable nodes.
/// </remarks>
public bool MaterializeAliasNodes { get; set; }
}
}
@@ -34,6 +34,7 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Opc.Ua.Security.Certificates;
using Opc.Ua.Server.AliasNames;
namespace Opc.Ua.Server
{
@@ -150,6 +151,60 @@ namespace Opc.Ua.Server
IPushCertificateKeyGenerator? keyGenerator = null,
IPushConfigurationTrustListEffectHandler? trustListEffectHandler = null,
ServerConfigurationOptions? serverConfigurationOptions = null)
: this(
server,
configuration,
logger,
timeProvider,
coordinator,
pendingKeyStore,
keyGenerator,
trustListEffectHandler,
serverConfigurationOptions,
aliasNameOptions: null)
{
}
/// <summary>
/// Initializes the configuration and diagnostics manager with explicit
/// PushManagement dependencies and optional alias-name materialization.
/// </summary>
/// <param name="server">The server.</param>
/// <param name="configuration">The application configuration.</param>
/// <param name="logger">The logger.</param>
/// <param name="timeProvider">
/// The timer and delay provider, or <see langword="null"/> to use the server's provider
/// when available and <see cref="TimeProvider.System"/> otherwise.
/// </param>
/// <param name="coordinator">
/// The shared transaction coordinator, or <see langword="null"/> to create the default.
/// </param>
/// <param name="pendingKeyStore">
/// The pending signing-request key store, or <see langword="null"/> to create the default.
/// </param>
/// <param name="keyGenerator">
/// The signing-request key generator, or <see langword="null"/> to create the default.
/// </param>
/// <param name="trustListEffectHandler">
/// The post-ApplyChanges TrustList effect handler, or <see langword="null"/> to create the default.
/// </param>
/// <param name="serverConfigurationOptions">
/// The optional ServerConfiguration surface, or <see langword="null"/> to use its defaults.
/// </param>
/// <param name="aliasNameOptions">
/// The alias-name address-space options, or <see langword="null"/> to leave materialization disabled.
/// </param>
public ConfigurationNodeManager(
IServerInternal server,
ApplicationConfiguration configuration,
ILogger logger,
TimeProvider? timeProvider,
IPushConfigurationTransactionCoordinator? coordinator,
IPendingCertificateKeyStore? pendingKeyStore,
IPushCertificateKeyGenerator? keyGenerator,
IPushConfigurationTrustListEffectHandler? trustListEffectHandler,
ServerConfigurationOptions? serverConfigurationOptions,
AliasNameServerOptions? aliasNameOptions)
: base(server, configuration, logger, timeProvider)
{
m_timeProvider = timeProvider
@@ -162,6 +217,7 @@ namespace Opc.Ua.Server
m_trustListEffectHandler = trustListEffectHandler
?? new PushConfigurationTrustListEffectHandler(server.Telemetry);
m_serverConfigurationOptions = serverConfigurationOptions ?? new ServerConfigurationOptions();
m_aliasNameOptions = aliasNameOptions ?? new AliasNameServerOptions();
CertificateStoreIdentifier? rejectedStore =
configuration.SecurityConfiguration.RejectedCertificateStore;
if (!string.IsNullOrEmpty(rejectedStore?.StorePath))
@@ -435,8 +491,8 @@ namespace Opc.Ua.Server
}
/// <summary>
/// Loads the predefined configuration nodes and then creates the
/// optional per-certificate-group alarm instances
/// Loads the predefined configuration nodes, materializes registered
/// aliases when enabled, and then creates the optional per-certificate-group alarm instances
/// (<c>CertificateExpired</c> and <c>TrustListOutOfDate</c>,
/// OPC 10000-12 §7.8.3). The alarm nodes are created here - once the
/// certificate-group nodes exist - and initialized in an inactive,
@@ -452,6 +508,12 @@ namespace Opc.Ua.Server
await base.CreateAddressSpaceAsync(externalReferences, cancellationToken)
.ConfigureAwait(false);
if (m_aliasNameOptions.MaterializeAliasNodes)
{
await MaterializeRegisteredAliasNameNodesAsync(externalReferences, cancellationToken)
.ConfigureAwait(false);
}
await CreateCertificateAlarmsAsync(
SystemContext,
externalReferences,
@@ -1099,6 +1161,7 @@ namespace Opc.Ua.Server
private readonly IPushCertificateKeyGenerator m_keyGenerator;
private readonly IPushConfigurationTrustListEffectHandler m_trustListEffectHandler;
private readonly ServerConfigurationOptions m_serverConfigurationOptions;
private readonly AliasNameServerOptions m_aliasNameOptions;
private ApplicationConfigurationFile? m_configurationFile;
private readonly List<ServerCertificateGroup> m_certificateGroups;
private readonly CertificateStoreIdentifier? m_rejectedStore;
@@ -278,7 +278,8 @@ namespace Opc.Ua.Server.Fluent
{
throw new ArgumentNullException(nameof(parent));
}
if (browseName.IsNull || string.IsNullOrEmpty(browseName.Name))
string? symbolicName = browseName.Name;
if (browseName.IsNull || symbolicName is null || symbolicName.Length == 0)
{
throw ServiceResultException.Create(
StatusCodes.BadBrowseNameInvalid,
@@ -293,7 +294,6 @@ namespace Opc.Ua.Server.Fluent
}
NodeId typeDef = typeDefinitionId.IsNull ? ObjectTypeIds.BaseObjectType : typeDefinitionId;
string symbolicName = browseName.Name ?? string.Empty;
var child = new BaseObjectState(parent.Node)
{
BrowseName = browseName,
@@ -31,6 +31,8 @@ using System;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Opc.Ua.Server.AliasNames;
namespace Opc.Ua.Server.Hosting
{
@@ -62,8 +64,9 @@ namespace Opc.Ua.Server.Hosting
}
throw new InvalidOperationException(
"The configured custom OPC UA server type does not derive from DependencyInjectionStandardServer. " +
"AddSessionManager, AddSubscriptionManager, AddDurableSubscriptions, ConfigureRoles and " +
"AddRoleManager require a DI-aware server type so their StandardServer hooks are not silently ignored.");
"AddSessionManager, AddSubscriptionManager, AddDurableSubscriptions, ConfigureRoles, " +
"AddRoleManager, ConfigureServerProperties, ConfigureResources and ConfigureAliasNames " +
"require a DI-aware server type so their StandardServer hooks are not silently ignored.");
}
private bool HasConstructorHookRegistrations()
@@ -72,7 +75,28 @@ namespace Opc.Ua.Server.Hosting
m_services.GetServices<OpcUaServerSubscriptionManagerRegistration>().Any() ||
m_services.GetService<OpcUaServerRoleManagerRegistration>() != null ||
m_services.GetService<ISubscriptionStore>() != null ||
m_services.GetService<IMonitoredItemQueueFactory>() != null;
m_services.GetService<IMonitoredItemQueueFactory>() != null ||
m_services.GetService<ServerProperties>() != null ||
m_services.GetServices<IConfigureOptions<ServerProperties>>().Any() ||
m_services.GetServices<IPostConfigureOptions<ServerProperties>>().Any() ||
m_services.GetServices<OpcUaServerResourceRegistration>().Any() ||
m_services.GetService<AliasNameServerOptions>() != null ||
m_services.GetServices<IConfigureOptions<AliasNameServerOptions>>().Any() ||
m_services.GetServices<IPostConfigureOptions<AliasNameServerOptions>>().Any() ||
HasConfiguredCompositionOptions();
}
private bool HasConfiguredCompositionOptions()
{
ServerProperties? properties = m_services.GetService<IOptions<ServerProperties>>()?.Value;
return (properties != null &&
(!string.IsNullOrEmpty(properties.ProductName) ||
!string.IsNullOrEmpty(properties.ProductUri) ||
!string.IsNullOrEmpty(properties.ManufacturerName) ||
!string.IsNullOrEmpty(properties.SoftwareVersion) ||
!string.IsNullOrEmpty(properties.BuildNumber) ||
properties.BuildDate != DateTime.MinValue)) ||
m_services.GetService<IOptions<AliasNameServerOptions>>()?.Value.MaterializeAliasNodes == true;
}
private readonly IServiceProvider m_services;
@@ -38,7 +38,7 @@ namespace Opc.Ua.Server.Hosting
/// <summary>
/// <see cref="StandardServer"/> variant that resolves supported server hooks from dependency injection.
/// </summary>
public class DependencyInjectionStandardServer : StandardServer
public class DependencyInjectionStandardServer : ReverseConnectServer
{
/// <summary>
/// Initializes a new instance of the <see cref="DependencyInjectionStandardServer"/> class.
@@ -59,6 +59,50 @@ namespace Opc.Ua.Server.Hosting
OpcUaServerRegistrationStaging.Apply(this, m_services);
}
/// <inheritdoc/>
protected override ServerProperties LoadServerProperties()
{
ServerProperties? properties = m_services.GetService<ServerProperties>() ??
m_services.GetService<IOptions<ServerProperties>>()?.Value;
ApplicationConfiguration configuration = Configuration ??
throw new InvalidOperationException("The server configuration has not been assigned.");
return new ServerProperties
{
ProductName = string.IsNullOrEmpty(properties?.ProductName)
? configuration.ApplicationName ?? string.Empty : properties.ProductName,
ProductUri = string.IsNullOrEmpty(properties?.ProductUri)
? configuration.ProductUri ?? string.Empty : properties.ProductUri,
ManufacturerName = properties?.ManufacturerName ?? string.Empty,
SoftwareVersion = string.IsNullOrEmpty(properties?.SoftwareVersion)
? Utils.GetAssemblySoftwareVersion() : properties.SoftwareVersion,
BuildNumber = string.IsNullOrEmpty(properties?.BuildNumber)
? Utils.GetAssemblyBuildNumber() : properties.BuildNumber,
BuildDate = properties?.BuildDate ?? DateTime.MinValue
};
}
/// <inheritdoc/>
protected override ResourceManager CreateResourceManager(
IServerInternal server,
ApplicationConfiguration configuration)
{
ResourceManager resources = base.CreateResourceManager(server, configuration);
try
{
foreach (OpcUaServerResourceRegistration registration in
m_services.GetServices<OpcUaServerResourceRegistration>())
{
registration.Apply(m_services, resources);
}
return resources;
}
catch
{
resources.Dispose();
throw;
}
}
/// <inheritdoc/>
protected override ISessionManager CreateSessionManager(
IServerInternal server,
@@ -159,11 +203,14 @@ namespace Opc.Ua.Server.Hosting
// set on the options. A DI-registered provider takes precedence.
ServerConfigurationOptions? serverConfigurationOptions =
ResolveServerConfigurationOptions(m_services);
AliasNameServerOptions? aliasNameOptions =
m_services.GetService<AliasNameServerOptions>() ??
m_services.GetService<IOptions<AliasNameServerOptions>>()?.Value;
bool hasSurface = serverConfigurationOptions != null;
return coordinator != null || pendingKeyStore != null || keyGenerator != null ||
trustListEffectHandler != null || hasSurface
trustListEffectHandler != null || hasSurface || aliasNameOptions != null
? new MainNodeManagerFactory(
configuration,
server,
@@ -171,7 +218,8 @@ namespace Opc.Ua.Server.Hosting
pendingKeyStore,
keyGenerator,
trustListEffectHandler,
serverConfigurationOptions)
serverConfigurationOptions,
aliasNameOptions)
: base.CreateMainNodeManagerFactory(server, configuration);
}
@@ -232,35 +280,6 @@ namespace Opc.Ua.Server.Hosting
return options;
}
/// <inheritdoc/>
protected override void OnNodeManagerStarted(IServerInternal server)
{
RegisterAliasNames(server);
base.OnNodeManagerStarted(server);
}
private void RegisterAliasNames(IServerInternal server)
{
if (server is not IAliasNameStoreRegistryProvider registryProvider)
{
return;
}
foreach (IAliasNameStoreRegistry registry in m_services.GetServices<IAliasNameStoreRegistry>())
{
foreach (IAliasNameStore store in registry.Stores)
{
registryProvider.AliasNameStoreRegistry.Register(store);
}
}
foreach (OpcUaServerAliasNameStoreRegistration registration in
m_services.GetServices<OpcUaServerAliasNameStoreRegistration>())
{
registryProvider.AliasNameStoreRegistry.Register(registration.Store);
}
}
private readonly IServiceProvider m_services;
}
}
@@ -49,6 +49,7 @@ namespace Opc.Ua.Server.Hosting
{
/// <summary>
/// Invoked once after the server has started.
/// A failure aborts the hosted server's startup and triggers cleanup.
/// </summary>
/// <param name="server">The live server context.</param>
/// <param name="cancellationToken">Cancellation token.</param>
@@ -0,0 +1,278 @@
/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Opc.Ua;
using Opc.Ua.Identity;
using Opc.Ua.Server;
using Opc.Ua.Server.AliasNames;
using Opc.Ua.Server.Hosting;
namespace Microsoft.Extensions.DependencyInjection
{
public static partial class OpcUaServerBuilderExtensions
{
/// <summary>
/// Configures the hosted server's BuildInfo. Unspecified product identity comes from the
/// effective application configuration; unspecified version numbers describe the stack.
/// A directly registered ServerProperties takes precedence over these options.
/// </summary>
/// <exception cref="ArgumentNullException"></exception>
public static IOpcUaServerBuilder ConfigureServerProperties(
this IOpcUaServerBuilder builder,
Action<ServerProperties> configure)
{
if (builder is null)
{
throw new ArgumentNullException(nameof(builder));
}
if (configure is null)
{
throw new ArgumentNullException(nameof(configure));
}
builder.Services.AddOptions<ServerProperties>().Configure(configure);
return builder;
}
/// <summary>
/// Registers an existing authenticator without transferring ownership of the instance.
/// Advertised user-token policies are not changed.
/// </summary>
/// <exception cref="ArgumentNullException"></exception>
public static IOpcUaServerBuilder AddIdentityAuthenticator(
this IOpcUaServerBuilder builder,
IUserTokenAuthenticator instance)
{
if (builder is null)
{
throw new ArgumentNullException(nameof(builder));
}
if (instance is null)
{
throw new ArgumentNullException(nameof(instance));
}
return builder.AddIdentityAuthenticator((_, _) => instance);
}
/// <summary>
/// Registers an authenticator factory evaluated with the effective server certificate validator.
/// Advertised user-token policies are not changed.
/// </summary>
/// <exception cref="ArgumentNullException"></exception>
public static IOpcUaServerBuilder AddIdentityAuthenticator(
this IOpcUaServerBuilder builder,
Func<IServiceProvider, ICertificateValidatorEx?, IUserTokenAuthenticator> factory)
{
if (builder is null)
{
throw new ArgumentNullException(nameof(builder));
}
if (factory is null)
{
throw new ArgumentNullException(nameof(factory));
}
builder.Services.AddSingleton(new OpcUaServerIdentityAuthenticatorRegistration(
(services, validator) =>
[
factory(services, validator) ??
throw new InvalidOperationException("The identity authenticator factory returned null.")
]));
return builder;
}
/// <summary>
/// Registers a singleton task run after the hosted server starts.
/// Repeated registration of the same task type is idempotent.
/// </summary>
/// <typeparam name="TTask">The startup task implementation type.</typeparam>
/// <exception cref="ArgumentNullException"></exception>
public static IOpcUaServerBuilder AddStartupTask<
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TTask>(
this IOpcUaServerBuilder builder)
where TTask : class, IServerStartupTask
{
if (builder is null)
{
throw new ArgumentNullException(nameof(builder));
}
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IServerStartupTask, TTask>());
return builder;
}
/// <summary>
/// Registers an asynchronous callback run after the hosted server starts.
/// Callbacks are awaited in registration order; failure aborts startup.
/// </summary>
/// <exception cref="ArgumentNullException"></exception>
public static IOpcUaServerBuilder AddStartupTask(
this IOpcUaServerBuilder builder,
Func<IServiceProvider, IServerContext, CancellationToken, ValueTask> callback)
{
if (builder is null)
{
throw new ArgumentNullException(nameof(builder));
}
if (callback is null)
{
throw new ArgumentNullException(nameof(callback));
}
builder.Services.AddSingleton<IServerStartupTask>(
services => new DelegateServerStartupTask(services, callback));
return builder;
}
/// <summary>
/// Registers an asynchronous node-manager factory instance for the regular hosted server.
/// The caller retains ownership of the factory; the server owns the node manager it creates.
/// </summary>
/// <exception cref="ArgumentNullException"></exception>
public static IOpcUaServerBuilder AddNodeManager(
this IOpcUaServerBuilder builder,
IAsyncNodeManagerFactory factory)
{
if (builder is null)
{
throw new ArgumentNullException(nameof(builder));
}
if (factory is null)
{
throw new ArgumentNullException(nameof(factory));
}
builder.Services.AddSingleton(new OpcUaServerNodeManagerRegistration(factory));
return builder;
}
/// <summary>
/// Registers a legacy node-manager factory instance for the regular hosted server.
/// The caller retains ownership of the factory; the server owns the node manager it creates.
/// </summary>
/// <exception cref="ArgumentNullException"></exception>
public static IOpcUaServerBuilder AddNodeManager(
this IOpcUaServerBuilder builder,
INodeManagerFactory factory)
{
if (builder is null)
{
throw new ArgumentNullException(nameof(builder));
}
if (factory is null)
{
throw new ArgumentNullException(nameof(factory));
}
builder.Services.AddSingleton(new OpcUaServerNodeManagerRegistration(factory));
return builder;
}
/// <summary>
/// Registers factories produced once per start after the effective application configuration is loaded.
/// An empty result is valid. The caller retains ownership of the factories.
/// </summary>
/// <exception cref="ArgumentNullException"></exception>
public static IOpcUaServerBuilder AddNodeManagers(
this IOpcUaServerBuilder builder,
Func<IServiceProvider, ApplicationConfiguration, ArrayOf<IAsyncNodeManagerFactory>> factories)
{
if (builder is null)
{
throw new ArgumentNullException(nameof(builder));
}
if (factories is null)
{
throw new ArgumentNullException(nameof(factories));
}
builder.Services.AddSingleton(new OpcUaServerNodeManagerRegistration(factories));
return builder;
}
/// <summary>
/// Configures opt-in startup materialization of standard alias categories on the DI server.
/// Later store mutations update queries, not the materialized browse tree.
/// </summary>
/// <exception cref="ArgumentNullException"></exception>
public static IOpcUaServerBuilder ConfigureAliasNames(
this IOpcUaServerBuilder builder,
Action<AliasNameServerOptions> configure)
{
if (builder is null)
{
throw new ArgumentNullException(nameof(builder));
}
if (configure is null)
{
throw new ArgumentNullException(nameof(configure));
}
builder.Services.AddOptions<AliasNameServerOptions>().Configure(configure);
return builder;
}
/// <summary>
/// Configures the server-owned resource manager after its default status-code text has loaded.
/// </summary>
/// <exception cref="ArgumentNullException"></exception>
public static IOpcUaServerBuilder ConfigureResources(
this IOpcUaServerBuilder builder,
Action<ResourceManager> configure)
{
if (builder is null)
{
throw new ArgumentNullException(nameof(builder));
}
if (configure is null)
{
throw new ArgumentNullException(nameof(configure));
}
return builder.ConfigureResources((_, resources) => configure(resources));
}
/// <summary>
/// Configures the server-owned resource manager using services from the container.
/// Callbacks run in registration order after the default status-code text has loaded.
/// </summary>
/// <exception cref="ArgumentNullException"></exception>
public static IOpcUaServerBuilder ConfigureResources(
this IOpcUaServerBuilder builder,
Action<IServiceProvider, ResourceManager> configure)
{
if (builder is null)
{
throw new ArgumentNullException(nameof(builder));
}
if (configure is null)
{
throw new ArgumentNullException(nameof(configure));
}
builder.Services.AddSingleton(new OpcUaServerResourceRegistration(configure));
return builder;
}
}
}
@@ -62,7 +62,7 @@ namespace Microsoft.Extensions.DependencyInjection
/// <see cref="IHostedService"/> so the .NET Generic Host owns its
/// lifetime, logging pipeline and Ctrl+C / SIGTERM handling.
/// </summary>
public static class OpcUaServerBuilderExtensions
public static partial class OpcUaServerBuilderExtensions
{
/// <summary>
/// Default <see cref="IConfiguration"/> section name used by the
@@ -1621,6 +1621,7 @@ namespace Microsoft.Extensions.DependencyInjection
services.AddOpcUa().AddApplicationInstance();
services.TryAddSingleton<IOpcUaServerFactory, DefaultOpcUaServerFactory>();
services.TryAddSingleton<HostedNodeManagerLifecycle>();
services.TryAddSingleton<OpcUaServerAliasNameStartupTask>();
services.TryAddSingleton<INodeManagerLifecycle>(services =>
services.GetRequiredService<HostedNodeManagerLifecycle>());
RegisterFallbackAnonymousAuthenticator(services);
@@ -0,0 +1,110 @@
/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Opc.Ua.Server.AliasNames;
namespace Opc.Ua.Server.Hosting
{
internal sealed class DelegateServerStartupTask : IServerStartupTask
{
public DelegateServerStartupTask(
IServiceProvider services,
Func<IServiceProvider, IServerContext, CancellationToken, ValueTask> callback)
{
m_services = services;
m_callback = callback;
}
public ValueTask OnServerStartedAsync(
IServerContext server,
CancellationToken cancellationToken = default)
{
return m_callback(m_services, server, cancellationToken);
}
private readonly IServiceProvider m_services;
private readonly Func<IServiceProvider, IServerContext, CancellationToken, ValueTask> m_callback;
}
internal sealed class OpcUaServerResourceRegistration
{
public OpcUaServerResourceRegistration(Action<IServiceProvider, ResourceManager> configure)
{
m_configure = configure;
}
public void Apply(IServiceProvider services, ResourceManager resources)
{
m_configure(services, resources);
}
private readonly Action<IServiceProvider, ResourceManager> m_configure;
}
internal sealed class OpcUaServerAliasNameStartupTask : IServerPreStartupTask
{
public OpcUaServerAliasNameStartupTask(IServiceProvider services)
{
m_services = services;
}
public ValueTask OnServerStartingAsync(
IServerContext server,
CancellationToken cancellationToken = default)
{
if (server is not IAliasNameStoreRegistryProvider provider)
{
throw new InvalidOperationException("The server does not provide an alias-name store registry.");
}
foreach (IAliasNameStoreRegistry registry in m_services.GetServices<IAliasNameStoreRegistry>())
{
foreach (IAliasNameStore store in registry.Stores)
{
cancellationToken.ThrowIfCancellationRequested();
provider.AliasNameStoreRegistry.Register(store);
}
}
foreach (OpcUaServerAliasNameStoreRegistration registration in
m_services.GetServices<OpcUaServerAliasNameStoreRegistration>())
{
cancellationToken.ThrowIfCancellationRequested();
provider.AliasNameStoreRegistry.Register(registration.Store);
}
return default;
}
private readonly IServiceProvider m_services;
}
}
@@ -41,7 +41,6 @@ using Opc.Ua.Configuration;
using Opc.Ua.Identity;
using Opc.Ua.Schema;
using Opc.Ua.Security.Certificates;
using Opc.Ua.Server.AliasNames;
using Opc.Ua.Server.Historian;
namespace Opc.Ua.Server.Hosting
@@ -60,7 +59,6 @@ namespace Opc.Ua.Server.Hosting
private readonly ITelemetryContext m_telemetry;
private readonly IApplicationInstanceFactory m_applicationFactory;
private readonly IOpcUaApplicationConfigurationProvider? m_configurationProvider;
private readonly IEnumerable<OpcUaServerNodeManagerRegistration> m_registrations;
private readonly IEnumerable<OpcUaServerIdentityAuthenticatorRegistration> m_identityRegistrations;
private readonly IEnumerable<OpcUaServerIdentityAugmenterRegistration> m_augmenterRegistrations;
private readonly IEnumerable<KeyCredentialPushSubject> m_keyCredentialPushSubjects;
@@ -70,7 +68,7 @@ namespace Opc.Ua.Server.Hosting
private readonly TimeProvider m_timeProvider;
private readonly ILogger<OpcUaServerHostedService> m_logger;
// CA2213: ApplicationInstance is IAsyncDisposable; it is owned either
// by this service and disposed in StopAsync or by the shared provider.
// by this service and disposed during execution cleanup or by the shared provider.
#pragma warning disable CA2213
private IApplicationInstance? m_application;
#pragma warning restore CA2213
@@ -85,7 +83,6 @@ namespace Opc.Ua.Server.Hosting
ITelemetryContext telemetry,
IApplicationInstanceFactory applicationFactory,
IEnumerable<IOpcUaApplicationConfigurationProvider> configurationProviders,
IEnumerable<OpcUaServerNodeManagerRegistration> registrations,
IEnumerable<OpcUaServerIdentityAuthenticatorRegistration> identityRegistrations,
IEnumerable<OpcUaServerIdentityAugmenterRegistration> augmenterRegistrations,
IEnumerable<KeyCredentialPushSubject> keyCredentialPushSubjects,
@@ -110,7 +107,6 @@ namespace Opc.Ua.Server.Hosting
{
m_configurationProvider = provider;
}
m_registrations = registrations ?? throw new ArgumentNullException(nameof(registrations));
m_identityRegistrations = identityRegistrations ??
throw new ArgumentNullException(nameof(identityRegistrations));
m_augmenterRegistrations = augmenterRegistrations ??
@@ -125,7 +121,39 @@ namespace Opc.Ua.Server.Hosting
m_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
/// Waits for server cleanup before the host disposes its injected services.
/// </summary>
public override async Task StopAsync(CancellationToken cancellationToken)
{
try
{
await base.StopAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
if (ExecuteTask is { } execution)
{
// Keep dependencies alive through cleanup; execution failures remain on ExecuteTask,
// matching BackgroundService.StopAsync rather than rethrowing them during shutdown.
await Task.WhenAny(execution).ConfigureAwait(false);
}
}
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
await RunServerAsync(stoppingToken).ConfigureAwait(false);
}
finally
{
await StopApplicationAsync(CancellationToken.None).ConfigureAwait(false);
}
}
private async Task RunServerAsync(CancellationToken stoppingToken)
{
ICertificateManager? certificateManager =
m_services.GetService<ICertificateManager>();
@@ -244,11 +272,16 @@ namespace Opc.Ua.Server.Hosting
m_server.RateLimitOptions = rateLimitOptions;
}
foreach (OpcUaServerNodeManagerRegistration reg in m_registrations)
foreach (OpcUaServerNodeManagerRegistration reg in
m_services.GetServices<OpcUaServerNodeManagerRegistration>())
{
if (reg.AsyncFactory is not null)
stoppingToken.ThrowIfCancellationRequested();
ArrayOf<IAsyncNodeManagerFactory> factories = reg.ResolveAsyncFactories(m_services, configuration);
foreach (IAsyncNodeManagerFactory factory in factories)
{
m_server.AddNodeManager(reg.AsyncFactory);
m_server.AddNodeManager(factory ??
throw new InvalidOperationException(
"The node-manager factories callback returned a null factory."));
}
if (reg.SyncFactory is not null)
{
@@ -257,7 +290,6 @@ namespace Opc.Ua.Server.Hosting
}
await application.StartAsync(m_server, stoppingToken).ConfigureAwait(false);
RegisterPostStartRegistries();
await BindKeyCredentialPushAsync(stoppingToken).ConfigureAwait(false);
RegisterIdentityAuthenticators();
RegisterIdentityAugmenters();
@@ -269,6 +301,7 @@ namespace Opc.Ua.Server.Hosting
{
try
{
stoppingToken.ThrowIfCancellationRequested();
await startupTask
.OnServerStartedAsync(m_server.CurrentInstance, stoppingToken)
.ConfigureAwait(false);
@@ -283,6 +316,7 @@ namespace Opc.Ua.Server.Hosting
{
m_logger.ServerStartupTaskStartupTaskFailedAfterServer(ex, startupTask.GetType().FullName);
}
throw;
}
}
@@ -472,32 +506,6 @@ namespace Opc.Ua.Server.Hosting
}
}
private void RegisterPostStartRegistries()
{
if (m_server is null or DependencyInjectionStandardServer)
{
return;
}
IServerInternal server = m_server.CurrentInstance;
if (server is IAliasNameStoreRegistryProvider aliasNameStoreRegistryProvider)
{
foreach (IAliasNameStoreRegistry registry in m_services.GetServices<IAliasNameStoreRegistry>())
{
foreach (IAliasNameStore store in registry.Stores)
{
aliasNameStoreRegistryProvider.AliasNameStoreRegistry.Register(store);
}
}
foreach (OpcUaServerAliasNameStoreRegistration registration in
m_services.GetServices<OpcUaServerAliasNameStoreRegistration>())
{
aliasNameStoreRegistryProvider.AliasNameStoreRegistry.Register(registration.Store);
}
}
}
private void RegisterIdentityAugmenters()
{
if (m_server == null)
@@ -612,24 +620,20 @@ namespace Opc.Ua.Server.Hosting
return false;
}
/// <summary>
/// Stops the hosted server and disposes the application instance when this service owns it.
/// </summary>
public override async Task StopAsync(CancellationToken cancellationToken)
private async ValueTask StopApplicationAsync(CancellationToken cancellationToken)
{
await base.StopAsync(cancellationToken).ConfigureAwait(false);
if (m_server is not null)
{
m_nodeManagerLifecycle.Detach(m_server.NodeManagerLifecycle);
}
if (m_application != null)
IApplicationInstance? application = Interlocked.Exchange(ref m_application, null);
if (application != null)
{
m_logger.StoppingOPCUAServer();
try
{
await m_application.StopAsync(cancellationToken).ConfigureAwait(false);
await application.StopAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
@@ -637,11 +641,18 @@ namespace Opc.Ua.Server.Hosting
}
finally
{
if (m_ownsApplication)
try
{
await m_application.DisposeAsync().ConfigureAwait(false);
if (m_ownsApplication)
{
await application.DisposeAsync().ConfigureAwait(false);
}
}
finally
{
m_server?.Dispose();
m_server = null;
}
m_application = null;
}
}
}
@@ -170,6 +170,9 @@ namespace Opc.Ua.Server.Hosting
registration.Resolve(services),
registration.OwnsProvider);
}
server.AddPreStartupTask(
services.GetService<OpcUaServerAliasNameStartupTask>() ??
new OpcUaServerAliasNameStartupTask(services));
foreach (IServerPreStartupTask task in
services.GetServices<IServerPreStartupTask>())
{
@@ -63,16 +63,32 @@ namespace Opc.Ua.Server.Hosting
SyncFactory = factory ?? throw new ArgumentNullException(nameof(factory));
}
internal OpcUaServerNodeManagerRegistration(
Func<IServiceProvider, ApplicationConfiguration, ArrayOf<IAsyncNodeManagerFactory>> factories)
{
m_factories = factories ?? throw new ArgumentNullException(nameof(factories));
}
/// <summary>
/// The asynchronous factory, if this is an async registration;
/// otherwise <c>null</c>.
/// The asynchronous factory instance, or <c>null</c> for a synchronous or deferred registration.
/// </summary>
public IAsyncNodeManagerFactory? AsyncFactory { get; }
/// <summary>
/// The synchronous factory, if this is a sync registration;
/// otherwise <c>null</c>.
/// The synchronous factory instance, or <c>null</c> for an asynchronous or deferred registration.
/// </summary>
public INodeManagerFactory? SyncFactory { get; }
internal ArrayOf<IAsyncNodeManagerFactory> ResolveAsyncFactories(
IServiceProvider services,
ApplicationConfiguration configuration)
{
return m_factories != null
? m_factories(services, configuration)
: AsyncFactory is { } factory ? [factory] : [];
}
private readonly Func<IServiceProvider, ApplicationConfiguration, ArrayOf<IAsyncNodeManagerFactory>>?
m_factories;
}
}
@@ -1271,6 +1271,7 @@ namespace Opc.Ua.Server
/// </remarks>
private const int kMaxStackallocChars = 256;
#if NETSTANDARD2_1_OR_GREATER || NET5_0_OR_GREATER
/// <summary>
/// The longest UTF-8 encoding of a path hashed on the stack.
/// </summary>
@@ -1281,6 +1282,7 @@ namespace Opc.Ua.Server
/// a stack sized path.
/// </remarks>
private const int kMaxStackallocBytes = kMaxStackallocChars * 3;
#endif
/// <summary>
/// The identifiers minted so far, each with a witness of the browse
@@ -27,6 +27,8 @@
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using Opc.Ua.Server.AliasNames;
namespace Opc.Ua.Server
{
/// <summary>
@@ -111,6 +113,51 @@ namespace Opc.Ua.Server
IPushCertificateKeyGenerator? keyGenerator = null,
IPushConfigurationTrustListEffectHandler? trustListEffectHandler = null,
ServerConfigurationOptions? serverConfigurationOptions = null)
: this(
applicationConfiguration,
server,
coordinator,
pendingKeyStore,
keyGenerator,
trustListEffectHandler,
serverConfigurationOptions,
aliasNameOptions: null)
{
}
/// <summary>
/// Initializes the main node-manager factory with explicit PushManagement
/// dependencies and optional alias-name materialization.
/// </summary>
/// <param name="applicationConfiguration">The application configuration.</param>
/// <param name="server">The server.</param>
/// <param name="coordinator">
/// The shared transaction coordinator, or <see langword="null"/> to create the default.
/// </param>
/// <param name="pendingKeyStore">
/// The pending signing-request key store, or <see langword="null"/> to create the default.
/// </param>
/// <param name="keyGenerator">
/// The signing-request key generator, or <see langword="null"/> to create the default.
/// </param>
/// <param name="trustListEffectHandler">
/// The post-ApplyChanges TrustList effect handler, or <see langword="null"/> to create the default.
/// </param>
/// <param name="serverConfigurationOptions">
/// The optional ServerConfiguration surface, or <see langword="null"/> to use its defaults.
/// </param>
/// <param name="aliasNameOptions">
/// The alias-name address-space options, or <see langword="null"/> to leave materialization disabled.
/// </param>
public MainNodeManagerFactory(
ApplicationConfiguration applicationConfiguration,
IServerInternal server,
IPushConfigurationTransactionCoordinator? coordinator,
IPendingCertificateKeyStore? pendingKeyStore,
IPushCertificateKeyGenerator? keyGenerator,
IPushConfigurationTrustListEffectHandler? trustListEffectHandler,
ServerConfigurationOptions? serverConfigurationOptions,
AliasNameServerOptions? aliasNameOptions)
{
m_applicationConfiguration = applicationConfiguration;
m_server = server;
@@ -119,6 +166,7 @@ namespace Opc.Ua.Server
m_keyGenerator = keyGenerator;
m_trustListEffectHandler = trustListEffectHandler;
m_serverConfigurationOptions = serverConfigurationOptions;
m_aliasNameOptions = aliasNameOptions;
}
/// <inheritdoc/>
@@ -133,7 +181,8 @@ namespace Opc.Ua.Server
m_pendingKeyStore,
m_keyGenerator,
m_trustListEffectHandler,
m_serverConfigurationOptions);
m_serverConfigurationOptions,
m_aliasNameOptions);
}
/// <inheritdoc/>
@@ -149,5 +198,6 @@ namespace Opc.Ua.Server
private readonly IPushCertificateKeyGenerator? m_keyGenerator;
private readonly IPushConfigurationTrustListEffectHandler? m_trustListEffectHandler;
private readonly ServerConfigurationOptions? m_serverConfigurationOptions;
private readonly AliasNameServerOptions? m_aliasNameOptions;
}
}
@@ -127,6 +127,7 @@ namespace Opc.Ua.Server
preferredLocales,
result.SymbolicId!,
result.NamespaceUri!,
result.StatusCode,
args!);
}
else
@@ -418,7 +419,8 @@ namespace Opc.Ua.Server
}
// construct translated localized text.
return new LocalizedText(culture.Name, translatedText, info);
return new LocalizedText(new TranslationInfo(
info.Key, culture.Name, translatedText, info.Args));
}
}
@@ -532,7 +534,8 @@ namespace Opc.Ua.Server
private LocalizedText TranslateStatusCode(
ArrayOf<string> preferredLocales,
StatusCode statusCode,
object[] args)
object[] args,
string? symbolicId = null)
{
lock (m_lock)
{
@@ -549,7 +552,7 @@ namespace Opc.Ua.Server
}
}
return LocalizedText.From(Utils.Format("{0:X8}", statusCode.Code));
return LocalizedText.From(symbolicId ?? Utils.Format("{0:X8}", statusCode.Code));
}
/// <summary>
@@ -559,6 +562,7 @@ namespace Opc.Ua.Server
ArrayOf<string> preferredLocales,
string symbolicId,
string namespaceUri,
StatusCode statusCode,
object[] args)
{
lock (m_lock)
@@ -578,6 +582,12 @@ namespace Opc.Ua.Server
}
}
if ((string.IsNullOrEmpty(namespaceUri) || namespaceUri == Opc.Ua.Namespaces.OpcUa) &&
symbolicId == new StatusCode(statusCode.Code).SymbolicId)
{
return TranslateStatusCode(preferredLocales, statusCode, args, symbolicId);
}
return LocalizedText.From(symbolicId);
}
@@ -187,12 +187,18 @@ namespace Opc.Ua.Server
{
base.OnServerStarted(server);
UpdateConfiguration(Configuration!);
StartTimer(true);
lock (m_connectionsLock)
{
m_reverseConnectStopped = false;
UpdateConfiguration(Configuration!);
StartTimer(true);
}
}
/// <inheritdoc />
protected override async ValueTask OnUpdateConfigurationAsync(ApplicationConfiguration configuration, CancellationToken cancellationToken = default)
protected override async ValueTask OnUpdateConfigurationAsync(
ApplicationConfiguration configuration,
CancellationToken cancellationToken = default)
{
await base.OnUpdateConfigurationAsync(configuration, cancellationToken)
.ConfigureAwait(false);
@@ -202,7 +208,7 @@ namespace Opc.Ua.Server
/// <inheritdoc />
protected override ValueTask OnServerStoppingAsync(CancellationToken cancellationToken = default)
{
DisposeTimer();
DisposeTimer(stopping: true);
return base.OnServerStoppingAsync(cancellationToken);
}
@@ -211,8 +217,7 @@ namespace Opc.Ua.Server
{
if (disposing)
{
m_reverseConnectTimer?.Dispose();
m_reverseConnectTimer = null;
DisposeTimer(stopping: true);
}
base.Dispose(disposing);
@@ -294,12 +299,18 @@ namespace Opc.Ua.Server
/// <summary>
/// Timer callback to establish new reverse connections.
/// </summary>
private void OnReverseConnect(object? state)
private void OnReverseConnect(long timerVersion)
{
try
{
lock (m_connectionsLock)
{
// Disposed timers can still have callbacks queued on the thread pool.
if (m_reverseConnectStopped || timerVersion != m_reverseConnectTimerVersion)
{
return;
}
foreach (ReverseConnectProperty reverseConnection in m_connections.Values)
{
// recharge a rejected connection after timeout
@@ -353,7 +364,13 @@ namespace Opc.Ua.Server
}
finally
{
StartTimer(true);
lock (m_connectionsLock)
{
if (timerVersion == m_reverseConnectTimerVersion)
{
StartTimer(true);
}
}
}
}
@@ -406,19 +423,21 @@ namespace Opc.Ua.Server
/// </summary>
private void StartTimer(bool forceRestart)
{
if (forceRestart)
{
DisposeTimer();
}
lock (m_connectionsLock)
{
if (m_connectInterval > 0 &&
if (forceRestart)
{
DisposeTimer();
}
if (!m_reverseConnectStopped &&
m_connectInterval > 0 &&
m_connections.Count > 0 &&
m_reverseConnectTimer == null)
{
long timerVersion = m_reverseConnectTimerVersion;
m_reverseConnectTimer = TimeProvider.CreateTimer(
OnReverseConnect,
this,
_ => OnReverseConnect(timerVersion),
null,
TimeSpan.FromMilliseconds(forceRestart ? m_connectInterval : 1000),
Timeout.InfiniteTimeSpan);
}
@@ -428,11 +447,15 @@ namespace Opc.Ua.Server
/// <summary>
/// Dispose the current timer.
/// </summary>
private void DisposeTimer()
private void DisposeTimer(bool stopping = false)
{
// start registration timer.
lock (m_connectionsLock)
{
if (stopping)
{
m_reverseConnectStopped = true;
}
m_reverseConnectTimerVersion++;
m_reverseConnectTimer?.Dispose();
m_reverseConnectTimer = null;
}
@@ -511,6 +534,8 @@ namespace Opc.Ua.Server
}
private ITimer? m_reverseConnectTimer;
private long m_reverseConnectTimerVersion;
private bool m_reverseConnectStopped;
private int m_connectInterval;
private int m_connectTimeout;
private int m_rejectTimeout;
@@ -100,6 +100,8 @@ namespace Opc.Ua.WotCon.Server
/// <summary>
/// Reconciles only the browseable AddressSpace. Registry change
/// transitions queued by the NodeManager remain the event authority.
/// Mutation handlers await this before returning so callers can read
/// the committed state without waiting for background reconciliation.
/// </summary>
public ValueTask ReconcileProjectionAsync(CancellationToken ct)
{
@@ -395,6 +397,7 @@ namespace Opc.Ua.WotCon.Server
WotRegistryMutationResult result = await m_registry
.SetEnabledAsync(groupId, resourceId, enabled, OptionalEpoch(input, 1), ct)
.ConfigureAwait(false);
await ReconcileProjectionAsync(ct).ConfigureAwait(false);
return ToServiceResult(result);
}
@@ -419,6 +422,7 @@ namespace Opc.Ua.WotCon.Server
WotRegistryMutationResult result = await m_registry
.SetDefaultVersionAsync(groupId, resourceId, versionId!, OptionalEpoch(input, 1), ct)
.ConfigureAwait(false);
await ReconcileProjectionAsync(ct).ConfigureAwait(false);
return ToServiceResult(result);
}
@@ -461,6 +465,10 @@ namespace Opc.Ua.WotCon.Server
: versionId;
WotResourceVersion? committedVersion =
committedResource?.FindVersion(committedVersionId);
if (ServiceResult.IsGood(serviceResult))
{
await ReconcileProjectionAsync(ct).ConfigureAwait(false);
}
return new WotResourceCommitResult(serviceResult, committedVersion);
}
@@ -718,8 +718,12 @@ namespace Opc.Ua.XRegistry.Server
}
}
#if NET5_0_OR_GREATER
return SHA256.HashData(buffer.ToArray());
#else
using var sha = SHA256.Create();
return sha.ComputeHash(buffer.ToArray());
#endif
}
private static void Append(MemoryStream buffer, byte[] value)
+515
View File
@@ -266,5 +266,520 @@ namespace Opc.Ua.Aot.Tests
ClientConfiguration = new ClientConfiguration()
};
}
[Test]
public async Task HostedCompositionRunsGenericAndDelegateExtensionsInAotAsync()
{
var observations = new AotCompositionObservations();
var token = new UserNameIdentityTokenHandler(
"aot-composition-user", Encoding.UTF8.GetBytes(Guid.NewGuid().ToString("N")));
var expectedIdentity = new UserIdentity(token);
var usernameAuthenticator = new UserNamePasswordAuthenticator((handler, ct) =>
{
observations.AuthenticatedToken = handler;
observations.AuthenticationCalls++;
return new ValueTask<IUserIdentity>(expectedIdentity);
});
using var aliases = new Server.AliasNames.InMemoryAliasNameStore(
[new Server.AliasNames.AliasNameCategoryDescriptor(
ObjectIds.TagVariables, QualifiedName.From(BrowseNames.TagVariables),
Server.AliasNames.AliasNameCapabilities.All)]);
aliases.Seed(ObjectIds.TagVariables, "AotBuildName",
VariableIds.Server_ServerStatus_BuildInfo_ProductName, null, ReferenceTypeIds.AliasFor);
var host = new AotCompositionHost(fixture.Telemetry, observations, builder =>
{
builder.ConfigureServerProperties(properties =>
{
properties.ProductName = "AOT Composed Product";
properties.ManufacturerName = "AOT Composition";
properties.SoftwareVersion = "4.5.6";
});
builder.ConfigureResources(resources =>
{
observations.DefaultStatusText = resources.Translate(
["en-US"], new ServiceResult(StatusCodes.BadNodeIdUnknown)).LocalizedText.Text;
resources.Add("AotComposition.Greeting", "en-US", "Hello from AOT");
});
builder.ConfigureResources((services, resources) =>
{
observations.ResourceDependency = services.GetRequiredService<AotCompositionObservations>();
resources.Add("AotComposition.Greeting", "de-DE", "Hallo aus AOT");
});
builder.AddIdentityAuthenticator<AnonymousAuthenticator>();
builder.AddIdentityAuthenticator(usernameAuthenticator);
builder.AddIdentityAuthenticator((services, validator) =>
{
observations.AuthenticatorDependency = services
.GetRequiredService<AotCompositionObservations>();
observations.CertificateValidator = validator;
observations.AuthenticatorFactoryCalls++;
return new AnonymousAuthenticator();
});
builder.AddNodeManager<AotCompositionNodeManagerFactory>();
builder.AddNodeManager(new AotValueNodeManagerFactory("urn:aot:composition:instance", 23));
builder.AddNodeManagers((services, configuration) =>
{
observations.ConfigurationDependency = services
.GetRequiredService<AotCompositionObservations>();
observations.Configuration = configuration;
observations.ConfigurationFactoryCalls++;
return [new AotValueNodeManagerFactory("urn:aot:composition:deferred", 37)];
});
builder.AddNodeManagers((_, _) =>
{
observations.EmptyFactoryCalls++;
return [];
});
builder.AddAliasNameStore(aliases);
builder.ConfigureAliasNames(options => options.MaterializeAliasNodes = true);
builder.AddStartupTask<AotCompositionStartupTask>();
builder.AddStartupTask<AotCompositionStartupTask>();
builder.AddStartupTask(async (services, context, ct) =>
{
observations.DelegateDependency = services.GetRequiredService<AotCompositionObservations>();
observations.DelegateContext = context;
observations.DelegateCancellationCanBeCanceled = ct.CanBeCanceled;
HistoryServerCapabilitiesState history = await context
.FindNodeManagers<IDiagnosticsNodeManager>().Single()
.GetDefaultHistoryCapabilitiesAsync(ct).ConfigureAwait(false);
observations.HistoryLimitAtDelegate = history.MaxReturnDataValues.Value;
observations.Events.Add("delegate");
});
});
await using var cleanup = host.ConfigureAwait(false);
await Assert.That(observations.FactoryConstructions).IsEqualTo(0);
await Assert.That(observations.AuthenticatorFactoryCalls).IsEqualTo(0);
await host.StartAsync().ConfigureAwait(false);
await Assert.That(host.Context.CurrentState).IsEqualTo(ServerState.Running);
BuildInfo info = ReadAotCompositionValue(host.Context, VariableIds.Server_ServerStatus_BuildInfo)
.WrappedValue.GetStructure<BuildInfo>();
await Assert.That(info.ProductName).IsEqualTo("AOT Composed Product");
await Assert.That(info.ProductUri).IsEqualTo("urn:aot:composition:product");
await Assert.That(info.ManufacturerName).IsEqualTo("AOT Composition");
await Assert.That(info.SoftwareVersion).IsEqualTo("4.5.6");
await Assert.That(info.BuildNumber).IsEqualTo(Utils.GetAssemblyBuildNumber());
await Assert.That(observations.Configuration.ApplicationName).IsEqualTo("AotComposedServer");
await Assert.That(observations.FactoryConstructions).IsEqualTo(1);
await Assert.That(observations.FactorySawConfiguration).IsTrue();
await Assert.That(observations.ConfigurationFactoryCalls).IsEqualTo(1);
await Assert.That(observations.EmptyFactoryCalls).IsEqualTo(1);
await Assert.That(observations.ConfigurationDependency).IsSameReferenceAs(observations);
await Assert.That(ReadAotCompositionMarker(host.Context, "urn:aot:composition:generic")).IsEqualTo(19);
await Assert.That(ReadAotCompositionMarker(host.Context, "urn:aot:composition:instance")).IsEqualTo(23);
await Assert.That(ReadAotCompositionMarker(host.Context, "urn:aot:composition:deferred")).IsEqualTo(37);
await Assert.That(observations.StartupCalls).IsEqualTo(1);
await Assert.That(observations.Events.SequenceEqual(["generic", "delegate"])).IsTrue();
await Assert.That(observations.DelegateDependency).IsSameReferenceAs(observations);
await Assert.That(observations.DelegateContext).IsSameReferenceAs(host.Context);
await Assert.That(observations.DelegateCancellationCanBeCanceled).IsTrue();
await Assert.That(observations.HistoryLimitAtDelegate).IsEqualTo(67u);
await Assert.That(observations.ResourceDependency).IsSameReferenceAs(observations);
await Assert.That(observations.DefaultStatusText).IsEqualTo("BadNodeIdUnknown");
LocalizedText greeting = observations.Server.ResourceManager.Translate(
["de-DE"], "AotComposition.Greeting", "fallback");
await Assert.That(greeting.Text).IsEqualTo("Hallo aus AOT");
await Assert.That(greeting.Locale).IsEqualTo("de-DE");
await Assert.That(observations.Server.ResourceManager.Translate(
["en-US"], "AotComposition.Greeting", "fallback").Text).IsEqualTo("Hello from AOT");
await Assert.That(observations.AuthenticatorFactoryCalls).IsEqualTo(1);
await Assert.That(observations.AuthenticatorDependency).IsSameReferenceAs(observations);
await Assert.That(observations.CertificateValidator)
.IsSameReferenceAs(observations.Configuration.CertificateManager);
var authenticationContext = new Identity.AuthenticationContext(
token, new UserTokenPolicy { TokenType = UserTokenType.UserName },
new EndpointDescription { SecurityMode = MessageSecurityMode.SignAndEncrypt },
host.Context.MessageContext);
Identity.AuthenticationResult identity = await observations.Server.IdentityRegistry
.AuthenticateAsync(authenticationContext, CancellationToken.None).ConfigureAwait(false);
await Assert.That(identity.Outcome).IsEqualTo(Identity.AuthenticationOutcome.Accepted);
await Assert.That(identity.Identity).IsSameReferenceAs(expectedIdentity);
await Assert.That(observations.AuthenticatedToken).IsSameReferenceAs(token);
await Assert.That(observations.AuthenticationCalls).IsEqualTo(1);
await Assert.That(observations.Configuration.ServerConfiguration.UserTokenPolicies
.ToArray().Select(policy => policy.TokenType).SequenceEqual([UserTokenType.Anonymous])).IsTrue();
AliasNameCategoryState category = host.Context
.FindPredefinedNode<AliasNameCategoryState>(ObjectIds.TagVariables);
await Assert.That(category.FindAliasVerbose).IsNotNull();
await Assert.That(category.AddAliasesToCategory).IsNotNull();
var argumentErrors = new List<ServiceResult>();
var output = new List<Variant>();
ServiceResult result = await category.FindAlias.CallAsync(
host.Context.DefaultSystemContext, category.NodeId,
[new Variant("Aot%"), new Variant(NodeId.Null)],
argumentErrors, output, CancellationToken.None).ConfigureAwait(false);
await Assert.That(ServiceResult.IsGood(result)).IsTrue();
await Assert.That(output.Count).IsEqualTo(1);
await Assert.That(output[0].TryGetStructure(out ArrayOf<AliasNameDataType> found)).IsTrue();
await Assert.That(found.Count).IsEqualTo(1);
await Assert.That(found[0].AliasName.Name).IsEqualTo("AotBuildName");
await host.StopAsync().ConfigureAwait(false);
await Assert.That(observations.GenericManager.Disposed).IsTrue();
await Assert.That(host.Listener.CloseCount > 0).IsTrue();
}
[Test]
public async Task HostedStartupDelegateFailureCleansUpInAotAsync()
{
var observations = new AotCompositionObservations();
var expected = new InvalidOperationException("aot-composition-startup-failure");
int laterCalls = 0;
var host = new AotCompositionHost(fixture.Telemetry, observations, builder =>
{
builder.AddNodeManager<AotCompositionNodeManagerFactory>();
builder.AddStartupTask((_, context, _) =>
{
observations.DelegateContext = context;
observations.MarkerAtFailure = ReadAotCompositionMarker(
context, "urn:aot:composition:generic");
throw expected;
});
builder.AddStartupTask((_, _, _) =>
{
laterCalls++;
return default;
});
});
await using var cleanup = host.ConfigureAwait(false);
Exception actual = null;
try
{
await host.StartAsync().ConfigureAwait(false);
}
catch (Exception exception)
{
actual = exception;
}
await Assert.That(actual).IsSameReferenceAs(expected);
await Assert.That(host.ExecuteTask.IsFaulted).IsTrue();
await Assert.That(observations.MarkerAtFailure).IsEqualTo(19);
await Assert.That(laterCalls).IsEqualTo(0);
await Assert.That(observations.GenericManager.Disposed).IsTrue();
await Assert.That(host.Listener.CloseCount > 0).IsTrue();
}
private static DataValue ReadAotCompositionValue(IServerContext context, NodeId nodeId)
{
BaseVariableState node = context.FindPredefinedNode<BaseVariableState>(nodeId)
?? throw new InvalidOperationException("The composed server did not publish the expected variable.");
return ReadAotCompositionValue(context, node);
}
private static DataValue ReadAotCompositionValue(IServerContext context, BaseVariableState node)
{
var value = new DataValue();
ServiceResult result = node.ReadAttribute(
context.DefaultSystemContext, Attributes.Value, NumericRange.Null, QualifiedName.Null, ref value);
if (ServiceResult.IsBad(result) || StatusCode.IsBad(value.StatusCode))
{
throw new InvalidOperationException("The composed server could not read its published variable.");
}
return value;
}
private static int ReadAotCompositionMarker(IServerContext context, string namespaceUri)
{
int namespaceIndex = context.DefaultSystemContext.NamespaceUris.GetIndex(namespaceUri);
if (namespaceIndex <= 0)
{
throw new InvalidOperationException("The composed node manager namespace was not registered.");
}
NodeId nodeId = new("Marker", checked((ushort)namespaceIndex));
BaseVariableState node = context.FindNodeManagers<AotValueNodeManager>()
.Select(manager => manager.FindPredefinedNode<BaseVariableState>(nodeId))
.Single(value => value != null);
return ReadAotCompositionValue(context, node).WrappedValue.GetInt32();
}
public sealed class AotCompositionObservations
{
public bool ConfigurationReady { get; set; }
public bool FactorySawConfiguration { get; set; }
public int FactoryConstructions { get; set; }
public int ConfigurationFactoryCalls { get; set; }
public int EmptyFactoryCalls { get; set; }
public int AuthenticatorFactoryCalls { get; set; }
public int AuthenticationCalls { get; set; }
public int StartupCalls { get; set; }
public int MarkerAtFailure { get; set; }
public uint HistoryLimitAtDelegate { get; set; }
public bool DelegateCancellationCanBeCanceled { get; set; }
public string DefaultStatusText { get; set; }
public IServerInternal Server { get; set; }
public IServerContext DelegateContext { get; set; }
public ApplicationConfiguration Configuration { get; set; }
public ICertificateValidatorEx CertificateValidator { get; set; }
public UserNameIdentityTokenHandler AuthenticatedToken { get; set; }
public AotCompositionObservations ConfigurationDependency { get; set; }
public AotCompositionObservations AuthenticatorDependency { get; set; }
public AotCompositionObservations ResourceDependency { get; set; }
public AotCompositionObservations DelegateDependency { get; set; }
public AotValueNodeManager GenericManager { get; set; }
public List<string> Events { get; } = [];
}
public sealed class AotCompositionStartupTask(AotCompositionObservations observations) : IServerStartupTask
{
public async ValueTask OnServerStartedAsync(
IServerContext server,
CancellationToken cancellationToken = default)
{
observations.StartupCalls++;
HistoryServerCapabilitiesState history = await server
.FindNodeManagers<IDiagnosticsNodeManager>().Single()
.GetDefaultHistoryCapabilitiesAsync(cancellationToken).ConfigureAwait(false);
history.MaxReturnDataValues.Value = 67;
history.MaxReturnEventValues.Value = 13;
observations.Events.Add("generic");
}
}
public sealed class AotCompositionNodeManagerFactory : IAsyncNodeManagerFactory
{
public AotCompositionNodeManagerFactory(AotCompositionObservations observations)
{
m_observations = observations;
observations.FactoryConstructions++;
observations.FactorySawConfiguration = observations.ConfigurationReady;
}
public ArrayOf<string> NamespacesUris => ["urn:aot:composition:generic"];
public ValueTask<IAsyncNodeManager> CreateAsync(
IServerInternal server,
ApplicationConfiguration configuration,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
m_observations.Server = server;
m_observations.GenericManager = new AotValueNodeManager(
server, configuration, NamespacesUris[0], 19);
return new ValueTask<IAsyncNodeManager>(m_observations.GenericManager);
}
private readonly AotCompositionObservations m_observations;
}
private sealed class AotValueNodeManagerFactory(string namespaceUri, int marker) : IAsyncNodeManagerFactory
{
public ArrayOf<string> NamespacesUris => [namespaceUri];
public AotValueNodeManager Manager { get; private set; }
public ValueTask<IAsyncNodeManager> CreateAsync(
IServerInternal server,
ApplicationConfiguration configuration,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
Manager = new AotValueNodeManager(server, configuration, namespaceUri, marker);
return new ValueTask<IAsyncNodeManager>(Manager);
}
}
public sealed class AotValueNodeManager(
IServerInternal server,
ApplicationConfiguration configuration,
string namespaceUri,
int marker) : AsyncCustomNodeManager(server, configuration, namespaceUri)
{
public bool Disposed { get; private set; }
public override async ValueTask CreateAddressSpaceAsync(
IDictionary<NodeId, IList<IReference>> externalReferences,
CancellationToken cancellationToken = default)
{
var variable = new BaseDataVariableState(null)
{
NodeId = new NodeId("Marker", NamespaceIndex),
BrowseName = new QualifiedName("Marker", NamespaceIndex),
DisplayName = LocalizedText.From("AOT Marker"),
TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
DataType = DataTypeIds.Int32,
ValueRank = ValueRanks.Scalar,
AccessLevel = AccessLevels.CurrentRead,
UserAccessLevel = AccessLevels.CurrentRead,
Value = marker
};
await AddPredefinedNodeAsync(SystemContext, variable, cancellationToken).ConfigureAwait(false);
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
Disposed = true;
}
base.Dispose(disposing);
}
}
private sealed class AotCompositionHost : IAsyncDisposable
{
public AotCompositionHost(
ITelemetryContext telemetry,
AotCompositionObservations observations,
Action<IOpcUaServerBuilder> compose)
{
m_root = Path.Combine(Path.GetTempPath(), "uaca", Guid.NewGuid().ToString("N")[..8]);
Directory.CreateDirectory(m_root);
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton(telemetry);
services.AddSingleton(observations);
var bindings = new Bindings.DefaultTransportBindingRegistry();
bindings.RegisterListenerFactory(new AotCompositionTransport(Listener));
services.AddSingleton<Bindings.ITransportBindingRegistry>(bindings);
IOpcUaServerBuilder builder = services.AddOpcUa().AddServer(options =>
{
options.ApplicationName = "AotComposedServer";
options.ApplicationUri = "urn:localhost:AotComposedServer";
options.ProductUri = "urn:aot:composition:product";
options.PkiRoot = m_root;
options.IncludeSignAndEncryptPolicies = false;
options.IncludeUnsecurePolicyNone = true;
options.AutoAcceptUntrustedCertificates = true;
options.EndpointUrls.Add("opc.tcp://localhost:0/AotComposedServer");
options.ConfigureBuilder = configuration =>
{
configuration.SetShutdownDelay(0);
observations.ConfigurationReady = true;
};
});
compose(builder);
builder.AddStartupTask((_, context, _) =>
{
Context = context;
m_ready.TrySetResult(true);
return default;
});
m_provider = services.BuildServiceProvider();
_ = HostedService;
}
public IServerContext Context { get; private set; }
public AotCompositionListener Listener { get; } = new();
public Task ExecuteTask => HostedService.ExecuteTask;
private BackgroundService HostedService => m_provider.GetServices<IHostedService>()
.OfType<BackgroundService>().Single();
public async Task StartAsync()
{
await HostedService.StartAsync(CancellationToken.None).ConfigureAwait(false);
Task completed = await Task.WhenAny(m_ready.Task, ExecuteTask)
.WaitAsync(TimeSpan.FromSeconds(60)).ConfigureAwait(false);
await completed.ConfigureAwait(false);
if (completed != m_ready.Task)
{
throw new InvalidOperationException("The AOT composition host stopped before startup completed.");
}
}
public async Task StopAsync()
{
if (m_stopped)
{
return;
}
m_stopped = true;
using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(20));
await HostedService.StopAsync(cancellation.Token).ConfigureAwait(false);
}
public async ValueTask DisposeAsync()
{
try
{
await StopAsync().ConfigureAwait(false);
}
finally
{
try
{
await m_provider.DisposeAsync().ConfigureAwait(false);
}
finally
{
if (Directory.Exists(m_root))
{
Directory.Delete(m_root, recursive: true);
}
}
}
}
private readonly string m_root;
private readonly ServiceProvider m_provider;
private readonly TaskCompletionSource<bool> m_ready =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private bool m_stopped;
}
private sealed class AotCompositionTransport(AotCompositionListener listener) : Bindings.TcpServiceHost
{
public override string UriScheme => Utils.UriSchemeOpcTcp;
public override ITransportListener Create(ITelemetryContext telemetry)
{
return listener;
}
}
private sealed class AotCompositionListener : ITransportListener
{
public string ListenerId => "aot-composition";
public string UriScheme => Utils.UriSchemeOpcTcp;
public int CloseCount { get; private set; }
public event ConnectionWaitingHandlerAsync ConnectionWaiting
{
add { }
remove { }
}
public event EventHandler<ConnectionStatusEventArgs> ConnectionStatusChanged
{
add { }
remove { }
}
public ValueTask OpenAsync(
Uri baseAddress,
TransportListenerSettings settings,
ITransportListenerCallback callback,
CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
return default;
}
public ValueTask CloseAsync(CancellationToken ct = default)
{
CloseCount++;
return default;
}
public ValueTask DisposeAsync()
{
return default;
}
public void CertificateUpdate(ICertificateValidatorEx validator, ICertificateRegistry serverCertificates)
{
}
public void CreateReverseConnection(Uri url, int timeout)
{
throw new InvalidOperationException("The AOT composition fixture must not create network connections.");
}
public void UpdateChannelLastActiveTime(string globalChannelId)
{
}
}
}
}
@@ -54,14 +54,17 @@ namespace Opc.Ua.Redundancy.Samples.Tests
/// <param name="assemblyName">The sample application assembly (dll) name without extension.</param>
/// <param name="arguments">The command-line arguments passed to the sample application.</param>
/// <param name="environment">Additional environment variables set for the process.</param>
/// <param name="writeOutput">Receives captured output, or uses the NUnit progress stream.</param>
public SampleAppProcess(
string name,
string applicationDirectory,
string assemblyName,
IReadOnlyList<string> arguments,
IReadOnlyDictionary<string, string?>? environment = null)
IReadOnlyDictionary<string, string?>? environment = null,
Action<string>? writeOutput = null)
{
Name = name;
m_writeOutput = writeOutput ?? TestContext.Progress.WriteLine;
string dll = LocateApplicationAssembly(applicationDirectory, assemblyName);
var startInfo = new ProcessStartInfo
{
@@ -125,7 +128,7 @@ namespace Opc.Ua.Redundancy.Samples.Tests
.ConfigureAwait(false) ??
throw new TimeoutException(
$"Sample process '{Name}' did not emit a line containing '{substring}' within {timeout}. " +
$"Process {(HasExited ? "has exited" : "is still running")}. " +
$"Process {(HasExited ? $"has exited (code {m_process.ExitCode})" : "is still running")}. " +
$"Last output:{Environment.NewLine}{GetOutputTail(20)}");
}
@@ -158,7 +161,12 @@ namespace Opc.Ua.Redundancy.Samples.Tests
if (HasExited)
{
await Task.Delay(250, cancellationToken).ConfigureAwait(false);
TimeSpan remaining = deadline - DateTime.UtcNow;
if (remaining <= TimeSpan.Zero ||
!await WaitForExitAndOutputAsync(remaining, cancellationToken).ConfigureAwait(false))
{
return null;
}
lock (m_lock)
{
for (; index < m_lines.Count; index++)
@@ -294,24 +302,13 @@ namespace Opc.Ua.Redundancy.Samples.Tests
}
/// <summary>
/// Waits for the process to exit, or the timeout to elapse.
/// Waits for process exit and redirected output completion, or the timeout to elapse.
/// </summary>
/// <param name="timeout">The maximum time to wait.</param>
/// <returns><c>true</c> when the process exited before the timeout.</returns>
public async Task<bool> WaitForExitAsync(TimeSpan timeout)
public Task<bool> WaitForExitAsync(TimeSpan timeout)
{
DateTime deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (m_process.HasExited)
{
return true;
}
await Task.Delay(50).ConfigureAwait(false);
}
return m_process.HasExited;
return WaitForExitAndOutputAsync(timeout, CancellationToken.None);
}
/// <summary>
@@ -342,6 +339,22 @@ namespace Opc.Ua.Redundancy.Samples.Tests
m_process.Dispose();
}
private async Task<bool> WaitForExitAndOutputAsync(TimeSpan timeout, CancellationToken cancellationToken)
{
using var deadline = new CancellationTokenSource(timeout);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, deadline.Token);
try
{
await m_process.WaitForExitAsync(linked.Token).ConfigureAwait(false);
return true;
}
catch (OperationCanceledException) when (deadline.IsCancellationRequested &&
!cancellationToken.IsCancellationRequested)
{
return false;
}
}
private void OnOutput(object sender, DataReceivedEventArgs e)
{
if (e.Data == null)
@@ -354,7 +367,7 @@ namespace Opc.Ua.Redundancy.Samples.Tests
m_lines.Add(e.Data);
}
TestContext.Progress.WriteLine($"[{Name}] {e.Data}");
m_writeOutput($"[{Name}] {e.Data}");
}
private string GetOutputTail(int maximumLines)
@@ -444,6 +457,7 @@ namespace Opc.Ua.Redundancy.Samples.Tests
}
private readonly Process m_process;
private readonly Action<string> m_writeOutput;
private readonly List<string> m_lines = [];
private readonly Lock m_lock = new();
}
@@ -0,0 +1,124 @@
/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
namespace Opc.Ua.Redundancy.Samples.Tests
{
/// <summary>
/// Exercises output delivery independently of the child process lifetime.
/// </summary>
[TestFixture]
[NonParallelizable]
public sealed class SampleAppProcessTests
{
[Test]
public async Task ExitedProcessWaitsForBufferedOutputAsync()
{
using var releaseOutput = new ManualResetEventSlim();
var outputEntered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var process = new SampleAppProcess(
"buffered-client",
"Redundancy/RedundantClient",
"RedundantClient",
["--help"],
writeOutput: _ =>
{
if (outputEntered.TrySetResult(true) &&
!releaseOutput.Wait(TimeSpan.FromSeconds(15)))
{
throw new TimeoutException("The buffered output callback was not released.");
}
});
await using var lifetime = process.ConfigureAwait(false);
try
{
await outputEntered.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
using var exitDeadline = new CancellationTokenSource(TimeSpan.FromSeconds(10));
while (!process.HasExited)
{
await Task.Delay(10, exitDeadline.Token).ConfigureAwait(false);
}
Task<string?> waiting = process.WaitForLineOrDefaultAsync("--identity", TimeSpan.FromSeconds(10));
Task completed = await Task.WhenAny(waiting, Task.Delay(TimeSpan.FromSeconds(1))).ConfigureAwait(false);
Assert.That(completed, Is.Not.SameAs(waiting),
"Process exit must not complete a line wait while redirected output is still being delivered.");
releaseOutput.Set();
string? line = await waiting.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
Assert.That(line, Does.Contain("--identity"));
}
finally
{
releaseOutput.Set();
}
}
[Test]
public async Task MissingLineAfterOutputCompletionReturnsNullAsync()
{
var process = new SampleAppProcess(
"missing-line-client",
"Redundancy/RedundantClient",
"RedundantClient",
["--help"],
writeOutput: _ => { });
await using var lifetime = process.ConfigureAwait(false);
string? line = await process.WaitForLineOrDefaultAsync(
"IDENTITY HA OK:", TimeSpan.FromSeconds(10)).ConfigureAwait(false);
Assert.That(line, Is.Null);
Assert.That(process.ContainsLine("--identity"), Is.True);
Assert.That(process.HasExited, Is.True);
}
[Test]
public async Task CancelledLineWaitPropagatesCancellationAsync()
{
var process = new SampleAppProcess(
"cancelled-line-client",
"Redundancy/RedundantClient",
"RedundantClient",
["--help"],
writeOutput: _ => { });
await using var lifetime = process.ConfigureAwait(false);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
Assert.That(async () => await process.WaitForLineOrDefaultAsync(
"IDENTITY HA OK:", TimeSpan.FromSeconds(10), cancellation.Token).ConfigureAwait(false),
Throws.InstanceOf<OperationCanceledException>());
}
}
}
@@ -34,6 +34,8 @@
using System;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Crdt;
using Crdt.Transport;
@@ -233,38 +235,104 @@ namespace Opc.Ua.Redundancy.Server.Tests
[Test]
public async Task ReconcileMaterializedValuesCorrectsStaleReplicaAndRebroadcastsAsync()
{
await using TwoReplicaFixture fixture = await TwoReplicaFixture.CreateAsync(this).ConfigureAwait(false);
await using var replicaA = new ControlledReplica(this, 1);
await using var replicaB = new ControlledReplica(this, 2);
await replicaA.StartAsync().ConfigureAwait(false);
await replicaB.StartAsync().ConfigureAwait(false);
await replicaA.SendToAsync(replicaB).ConfigureAwait(false);
await replicaB.SendToAsync(replicaA).ConfigureAwait(false);
BaseDataVariableState source = NewVariable("reconcile", 1.0);
await fixture.SpaceA.AddOrUpdateNodeAsync(source).ConfigureAwait(false);
await AssertEventuallyAsync(
() => fixture.SpaceB.TryGetNode(source.NodeId, out _),
"the seed node should replicate before reconciliation").ConfigureAwait(false);
await replicaA.Space.AddOrUpdateNodeAsync(source).ConfigureAwait(false);
await replicaA.SendToAsync(replicaB).ConfigureAwait(false);
await replicaB.SendToAsync(replicaA).ConfigureAwait(false);
source.Value = new Variant(42.0);
source.ClearChangeMasks(m_systemContext, false);
await AssertEventuallyAsync(
() => fixture.SpaceB.TryGetNode(source.NodeId, out NodeState? remote) &&
remote is BaseVariableState variable &&
variable.Value.Equals(new Variant(42.0)),
"the authoritative value should replicate before making B stale").ConfigureAwait(false);
var expected = new DataValue(source.Value, source.StatusCode, source.Timestamp);
await replicaA.SendToAsync(replicaB).ConfigureAwait(false);
await replicaB.SendToAsync(replicaA).ConfigureAwait(false);
AssertValue(replicaB, source.NodeId, in expected);
Assert.That(replicaA.Transport.HasSentFrame, Is.False);
Assert.That(replicaB.Transport.HasSentFrame, Is.False);
BaseVariableState staleVariable = replicaB.GetVariable(source.NodeId);
staleVariable.Value = new Variant(11.0);
staleVariable.StatusCode = StatusCodes.BadOutOfService;
staleVariable.Timestamp = DateTimeUtc.Now;
int topologyUpdates = 0;
replicaB.Space.NodeAdded += _ => Interlocked.Increment(ref topologyUpdates);
long inboundApplyCount = replicaA.Sync.InboundApplyCount;
Task rebroadcastApplied = replicaA.Sync.WaitForInboundApplyAfterAsync(inboundApplyCount);
await replicaA.Sync.SeedOrHydrateAsync().ConfigureAwait(false);
await replicaA.SendToAsync(replicaB).ConfigureAwait(false);
AssertValue(replicaB, source.NodeId, in expected);
Assert.That(topologyUpdates, Is.Zero, "reconciliation must not depend on a topology diff");
Assert.That(rebroadcastApplied.IsCompleted, Is.False, "the correcting frame has not reached A yet");
await replicaB.SendToAsync(replicaA).ConfigureAwait(false);
await AwaitWithTimeoutAsync(rebroadcastApplied).ConfigureAwait(false);
AssertValue(replicaA, source.NodeId, in expected);
Assert.That(replicaA.Sync.InboundApplyCount, Is.EqualTo(inboundApplyCount + 1));
Assert.That(replicaA.Transport.HasSentFrame, Is.False);
Assert.That(replicaB.Transport.HasSentFrame, Is.False);
}
[TestCase(false)]
[TestCase(true)]
public async Task ReconciliationRebroadcastCompletesBeforeReseedAsync(bool registerAfterApply)
{
await using var replicaA = new ControlledReplica(this, 1);
await using var replicaB = new ControlledReplica(this, 2);
await replicaA.StartAsync().ConfigureAwait(false);
await replicaB.StartAsync().ConfigureAwait(false);
await replicaA.SendToAsync(replicaB).ConfigureAwait(false);
await replicaB.SendToAsync(replicaA).ConfigureAwait(false);
BaseDataVariableState source = NewVariable("reconcile-before-reseed", 1.0);
await replicaA.Space.AddOrUpdateNodeAsync(source).ConfigureAwait(false);
await replicaA.SendToAsync(replicaB).ConfigureAwait(false);
source.Value = new Variant(42.0);
source.ClearChangeMasks(m_systemContext, false);
var expected = new DataValue(source.Value, source.StatusCode, source.Timestamp);
await replicaA.SendToAsync(replicaB).ConfigureAwait(false);
AssertValue(replicaB, source.NodeId, in expected);
BaseVariableState staleVariable = replicaB.GetVariable(source.NodeId);
Assert.That(fixture.SpaceB.TryGetNode(source.NodeId, out NodeState? staleNode), Is.True);
var staleVariable = (BaseVariableState)staleNode!;
staleVariable.Value = new Variant(11.0);
staleVariable.StatusCode = StatusCodes.Good;
staleVariable.Timestamp = DateTimeUtc.Now;
await fixture.DrainAsync().ConfigureAwait(false);
long inboundApplyCount = fixture.SyncA.InboundApplyCount;
await fixture.SyncA.SeedOrHydrateAsync().ConfigureAwait(false);
// The delayed echo predates A's write, so A sends its current state back to B.
await replicaB.SendToAsync(replicaA).ConfigureAwait(false);
await replicaA.SendToAsync(replicaB).ConfigureAwait(false);
AssertValue(replicaB, source.NodeId, in expected);
await AssertEventuallyAsync(
() => fixture.SpaceB.TryGetNode(source.NodeId, out NodeState? remote) &&
remote is BaseVariableState variable &&
variable.Value.Equals(new Variant(42.0)),
"a zero-diff merge should reconcile the stale materialized value on B").ConfigureAwait(false);
await AwaitWithTimeoutAsync(fixture.SyncA.WaitForInboundApplyAfterAsync(inboundApplyCount)).ConfigureAwait(false);
// Consume the earlier value echo separately from the reconciliation rebroadcast.
await replicaB.SendToAsync(replicaA).ConfigureAwait(false);
long correctionApplyCount = replicaA.Sync.InboundApplyCount;
Task? rebroadcastApplied = registerAfterApply ?
null :
replicaA.Sync.WaitForInboundApplyAfterAsync(correctionApplyCount);
await replicaB.SendToAsync(replicaA).ConfigureAwait(false);
rebroadcastApplied ??= replicaA.Sync.WaitForInboundApplyAfterAsync(correctionApplyCount);
await AwaitWithTimeoutAsync(rebroadcastApplied).ConfigureAwait(false);
AssertValue(replicaA, source.NodeId, in expected);
Assert.That(replicaA.Sync.InboundApplyCount, Is.EqualTo(correctionApplyCount + 1));
Assert.That(replicaA.Transport.HasSentFrame, Is.False);
Assert.That(replicaB.Transport.HasSentFrame, Is.False);
long inboundApplyCount = replicaA.Sync.InboundApplyCount;
await replicaA.Sync.SeedOrHydrateAsync().ConfigureAwait(false);
await replicaA.SendToAsync(replicaB).ConfigureAwait(false);
AssertValue(replicaB, source.NodeId, in expected);
Assert.That(replicaA.Transport.HasSentFrame, Is.False);
Assert.That(replicaB.Transport.HasSentFrame, Is.False);
Assert.That(replicaA.Sync.InboundApplyCount, Is.EqualTo(inboundApplyCount),
"a peer that was already reconciled must not echo the no-op reseed");
}
[Test]
@@ -382,17 +450,102 @@ namespace Opc.Ua.Redundancy.Server.Tests
TimeSpan.FromSeconds(30);
}
private static void AssertValue(ControlledReplica replica, NodeId nodeId, in DataValue expected)
{
BaseVariableState actual = replica.GetVariable(nodeId);
Assert.That(actual.Value, Is.EqualTo(expected.WrappedValue));
Assert.That(actual.StatusCode, Is.EqualTo(expected.StatusCode));
Assert.That(actual.Timestamp, Is.EqualTo(expected.SourceTimestamp));
}
private sealed class ControlledReplica : IAsyncDisposable
{
public ControlledReplica(ReplicatedAddressSpaceSynchronizerTests test, ulong replicaId)
{
Space = new DictionaryAddressSpace(test.m_systemContext);
Transport = new ControlledTransport();
Sync = new ReplicatedAddressSpaceSynchronizer(
Space, test.m_messageContext, ReplicaId.FromUInt64(replicaId),
Transport, TimeProvider.System, CrdtReaderOptions.Default);
}
public DictionaryAddressSpace Space { get; }
public ControlledTransport Transport { get; }
public ReplicatedAddressSpaceSynchronizer Sync { get; }
public async Task StartAsync()
{
await Sync.SeedOrHydrateAsync().ConfigureAwait(false);
Sync.Start();
}
public async Task SendToAsync(ControlledReplica peer)
{
long observedCount = peer.Sync.InboundApplyCount;
Task applied = peer.Sync.WaitForInboundApplyAfterAsync(observedCount);
peer.Transport.Receive(Transport.TakeSentFrame());
await AwaitWithTimeoutAsync(applied).ConfigureAwait(false);
Assert.That(peer.Sync.InboundApplyCount, Is.EqualTo(observedCount + 1));
}
public BaseVariableState GetVariable(NodeId nodeId)
{
Assert.That(Space.TryGetNode(nodeId, out NodeState? node), Is.True);
Assert.That(node, Is.InstanceOf<BaseVariableState>());
return (BaseVariableState)node!;
}
public ValueTask DisposeAsync()
{
return Sync.DisposeAsync();
}
}
private sealed class ControlledTransport : ITransport
{
public event Action<ReadOnlyMemory<byte>>? FrameReceived;
public bool HasSentFrame => m_sent.Reader.TryPeek(out _);
public ByteString TakeSentFrame()
{
Assert.That(m_sent.Reader.TryRead(out ByteString frame), Is.True,
"the completed operation must have sent a frame");
return frame;
}
public void Receive(ByteString frame)
{
FrameReceived?.Invoke(frame.ToArray());
}
public ValueTask StartAsync(CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
return default;
}
public ValueTask SendAsync(ReadOnlyMemory<byte> frame, CancellationToken ct = default)
{
Assert.That(FrameCodec.Decode(frame).MessageType, Is.EqualTo(MessageType.State));
return m_sent.Writer.WriteAsync(new ByteString(frame.ToArray()), ct);
}
public ValueTask DisposeAsync()
{
m_sent.Writer.TryComplete();
return default;
}
private readonly Channel<ByteString> m_sent = Channel.CreateUnbounded<ByteString>();
}
private sealed class TwoReplicaFixture : IAsyncDisposable
{
public DictionaryAddressSpace SpaceA { get; private set; } = null!;
public DictionaryAddressSpace SpaceB { get; private set; } = null!;
public ReplicatedAddressSpaceSynchronizer SyncA => m_syncA!;
public ReplicatedAddressSpaceSynchronizer SyncB => m_syncB!;
public ValueTask DrainAsync()
{
return m_network!.DrainAsync(default);
}
public static async Task<TwoReplicaFixture> CreateAsync(ReplicatedAddressSpaceSynchronizerTests test)
{
@@ -0,0 +1,323 @@
/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using NUnit.Framework;
using Opc.Ua.Server.AliasNames;
using Opc.Ua.Tests;
namespace Opc.Ua.Server.Tests.AliasNames
{
/// <summary>
/// Verifies opt-in alias materialization through the normal configuration
/// node manager and its factory without starting a network listener.
/// </summary>
[TestFixture]
[Category("AliasNames")]
[Parallelizable]
public sealed class StandardAliasMaterializationOptionsTests
{
[SetUp]
public async Task SetUpAsync()
{
m_registry = new AliasNameStoreRegistry();
m_store = new InMemoryAliasNameStore(
[
new AliasNameCategoryDescriptor(
ObjectIds.TagVariables,
new QualifiedName(BrowseNames.TagVariables),
AliasNameCapabilities.All)
]);
m_registry.Register(m_store);
await m_store.AddAliasesAsync(
ObjectIds.TagVariables,
[
new AliasAddRequest(
kAliasName,
new ExpandedNodeId(kTargetName, 1),
null,
ReferenceTypeIds.AliasFor)
],
CancellationToken.None).ConfigureAwait(false);
m_server = CreateServer();
m_configuration = new ApplicationConfiguration
{
ServerConfiguration = new ServerConfiguration(),
SecurityConfiguration = new SecurityConfiguration()
};
}
[TearDown]
public void TearDown()
{
m_server?.Dispose();
m_registry?.Dispose();
m_store?.Dispose();
}
[Test]
public void MaterializationIsDisabledByDefault()
{
var options = new AliasNameServerOptions();
Assert.That(options.MaterializeAliasNodes, Is.False);
}
[TestCase(false)]
[TestCase(true)]
public async Task ConfigurationManagerHonorsMaterializationOptionAsync(bool enabled)
{
using var manager = new ConfigurationNodeManager(
m_server,
m_configuration,
NullLogger.Instance,
timeProvider: null,
coordinator: null,
pendingKeyStore: null,
keyGenerator: null,
trustListEffectHandler: null,
serverConfigurationOptions: new ServerConfigurationOptions { HasSecureElement = true },
aliasNameOptions: new AliasNameServerOptions { MaterializeAliasNodes = enabled });
await AssertMaterializationAsync(manager, enabled).ConfigureAwait(false);
ServerConfigurationState configuration =
manager.FindPredefinedNode<ServerConfigurationState>(ObjectIds.ServerConfiguration);
Assert.That(configuration.HasSecureElement, Is.Not.Null,
"The new overload must also retain the existing ServerConfiguration options.");
}
[TestCase(false)]
[TestCase(true)]
public async Task FactoryPropagatesMaterializationOptionAsync(bool enabled)
{
var factory = new MainNodeManagerFactory(
m_configuration,
m_server,
coordinator: null,
pendingKeyStore: null,
keyGenerator: null,
trustListEffectHandler: null,
serverConfigurationOptions: new ServerConfigurationOptions { HasSecureElement = true },
aliasNameOptions: new AliasNameServerOptions { MaterializeAliasNodes = enabled });
using var manager = (ConfigurationNodeManager)factory.CreateConfigurationNodeManager();
await AssertMaterializationAsync(manager, enabled).ConfigureAwait(false);
ServerConfigurationState configuration =
manager.FindPredefinedNode<ServerConfigurationState>(ObjectIds.ServerConfiguration);
Assert.That(configuration.HasSecureElement, Is.Not.Null,
"The factory must continue forwarding the existing ServerConfiguration options.");
}
[TestCase(false)]
[TestCase(true)]
public async Task NullOptionsLeaveMaterializationDisabledAsync(bool throughFactory)
{
using ConfigurationNodeManager manager = throughFactory
? (ConfigurationNodeManager)new MainNodeManagerFactory(
m_configuration,
m_server,
coordinator: null,
pendingKeyStore: null,
keyGenerator: null,
trustListEffectHandler: null,
serverConfigurationOptions: null,
aliasNameOptions: null).CreateConfigurationNodeManager()
: new ConfigurationNodeManager(
m_server,
m_configuration,
NullLogger.Instance,
timeProvider: null,
coordinator: null,
pendingKeyStore: null,
keyGenerator: null,
trustListEffectHandler: null,
serverConfigurationOptions: null,
aliasNameOptions: null);
await AssertMaterializationAsync(manager, enabled: false).ConfigureAwait(false);
}
[TestCase(2)]
[TestCase(3)]
[TestCase(4)]
[TestCase(9)]
public async Task ExistingConfigurationConstructorsLeaveMaterializationDisabledAsync(int parameterCount)
{
using ConfigurationNodeManager manager = parameterCount switch
{
2 => new ConfigurationNodeManager(m_server, m_configuration),
3 => new ConfigurationNodeManager(m_server, m_configuration, NullLogger.Instance),
4 => new ConfigurationNodeManager(m_server, m_configuration, NullLogger.Instance, timeProvider: null),
9 => new ConfigurationNodeManager(
m_server,
m_configuration,
NullLogger.Instance,
timeProvider: null,
coordinator: null,
pendingKeyStore: null,
keyGenerator: null,
trustListEffectHandler: null,
serverConfigurationOptions: null),
_ => throw new ArgumentOutOfRangeException(nameof(parameterCount))
};
await AssertMaterializationAsync(manager, enabled: false).ConfigureAwait(false);
}
[TestCase(2)]
[TestCase(3)]
[TestCase(7)]
public async Task ExistingFactoryConstructorsLeaveMaterializationDisabledAsync(int parameterCount)
{
MainNodeManagerFactory factory = parameterCount switch
{
2 => new MainNodeManagerFactory(m_configuration, m_server),
3 => new MainNodeManagerFactory(m_configuration, m_server, coordinator: null),
7 => new MainNodeManagerFactory(
m_configuration,
m_server,
coordinator: null,
pendingKeyStore: null,
keyGenerator: null,
trustListEffectHandler: null,
serverConfigurationOptions: null),
_ => throw new ArgumentOutOfRangeException(nameof(parameterCount))
};
using var manager = (ConfigurationNodeManager)factory.CreateConfigurationNodeManager();
await AssertMaterializationAsync(manager, enabled: false).ConfigureAwait(false);
}
private IServerInternal CreateServer()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
var namespaces = new NamespaceTable();
namespaces.Append("urn:opcfoundation.org:tests:standard-alias-materialization");
var serverUris = new StringTable();
var factory = EncodeableFactory.Create();
var server = new Mock<IServerInternal>();
server.As<IAliasNameStoreRegistryProvider>()
.Setup(provider => provider.AliasNameStoreRegistry)
.Returns(m_registry);
server.Setup(instance => instance.Telemetry).Returns(telemetry);
server.Setup(instance => instance.NamespaceUris).Returns(namespaces);
server.Setup(instance => instance.ServerUris).Returns(serverUris);
server.Setup(instance => instance.TypeTree).Returns(new TypeTable(namespaces));
server.Setup(instance => instance.Factory).Returns(factory);
server.Setup(instance => instance.MessageContext)
.Returns(new ServiceMessageContext(telemetry, factory)
{
NamespaceUris = namespaces,
ServerUris = serverUris
});
server.Setup(instance => instance.CoreNodeManager).Returns(Mock.Of<ICoreNodeManager>());
server.Setup(instance => instance.NodeManager).Returns(Mock.Of<IMasterNodeManager>());
server.Setup(instance => instance.SubscriptionManager).Returns(Mock.Of<ISubscriptionManager>());
server.Setup(instance => instance.DefaultSystemContext)
.Returns(new ServerSystemContext(server.Object));
return server.Object;
}
private async Task AssertMaterializationAsync(ConfigurationNodeManager manager, bool enabled)
{
var externalReferences = new Dictionary<NodeId, IList<IReference>>();
await manager.CreateAddressSpaceAsync(externalReferences, CancellationToken.None).ConfigureAwait(false);
AliasNameCategoryState category =
manager.FindPredefinedNode<AliasNameCategoryState>(ObjectIds.TagVariables);
Assert.That(category, Is.Not.Null);
Assert.That(category.FindAlias, Is.Not.Null);
Assert.That(category.FindAlias.OnCallAsync, Is.Not.Null,
"Alias query methods must remain available independently of materialization.");
ushort namespaceIndex = m_server.NamespaceUris.GetIndexOrAppend(Ua.Namespaces.OpcUa + "Diagnostics");
var aliasId = new NodeId(Utils.Format("{0}.{1}", ObjectIds.TagVariables, kAliasName), namespaceIndex);
AliasNameState alias = manager.FindPredefinedNode<AliasNameState>(aliasId);
var targetId = new NodeId(kTargetName, 1);
if (enabled)
{
Assert.That(alias, Is.Not.Null);
Assert.That(alias.TypeDefinitionId, Is.EqualTo(ObjectTypeIds.AliasNameType));
Assert.That(alias.BrowseName, Is.EqualTo(new QualifiedName(kAliasName, namespaceIndex)));
Assert.That(category.ReferenceExists(ReferenceTypeIds.Organizes, false, aliasId), Is.True);
Assert.That(alias.ReferenceExists(ReferenceTypeIds.AliasFor, false, targetId), Is.True);
Assert.That(externalReferences.TryGetValue(targetId, out IList<IReference> references), Is.True);
Assert.That(references, Has.Count.EqualTo(1));
Assert.That(references[0].ReferenceTypeId, Is.EqualTo(ReferenceTypeIds.AliasFor));
Assert.That(references[0].IsInverse, Is.True);
Assert.That(references[0].TargetId, Is.EqualTo(new ExpandedNodeId(aliasId)));
Assert.That(category.FindAliasVerbose?.OnCallAsync, Is.Not.Null);
Assert.That(category.AddAliasesToCategory?.OnCallAsync, Is.Not.Null);
Assert.That(category.DeleteAliasesFromCategory?.OnCallAsync, Is.Not.Null);
Assert.That(category.LastChange, Is.Not.Null);
}
else
{
Assert.That(alias, Is.Null);
Assert.That(externalReferences.ContainsKey(targetId), Is.False);
Assert.That(category.FindAliasVerbose, Is.Null);
Assert.That(category.AddAliasesToCategory, Is.Null);
Assert.That(category.DeleteAliasesFromCategory, Is.Null);
}
var output = new List<Variant>();
ServiceResult result = await category.FindAlias.CallAsync(
manager.SystemContext,
category.NodeId,
[new Variant(kAliasName), new Variant(NodeId.Null)],
[],
output,
CancellationToken.None).ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(result), Is.True);
Assert.That(output, Has.Count.EqualTo(1));
Assert.That(output[0].TryGetStructure(out ArrayOf<AliasNameDataType> aliases), Is.True);
Assert.That(aliases.Count, Is.EqualTo(1));
Assert.That(aliases[0].AliasName.Name, Is.EqualTo(kAliasName));
Assert.That(aliases[0].ReferencedNodes, Is.EqualTo(new[] { new ExpandedNodeId(targetId) }));
}
private const string kAliasName = "Temperature";
private const string kTargetName = "TemperatureValue";
private AliasNameStoreRegistry m_registry;
private InMemoryAliasNameStore m_store;
private IServerInternal m_server;
private ApplicationConfiguration m_configuration;
}
}
@@ -207,7 +207,7 @@ namespace Opc.Ua.Server.Tests.Fluent
// document's back.
Assert.That(
BrowseNames(overlayDevice),
Is.EquivalentTo(new[] { "Calibrate" }));
Is.EquivalentTo(s_overlayDeviceBrowseNames));
// The generated Device exposes the imported replacement, and
// exposes it exactly once.
Assert.That(
@@ -338,5 +338,7 @@ namespace Opc.Ua.Server.Tests.Fluent
return response;
}
private static readonly string[] s_overlayDeviceBrowseNames = ["Calibrate"];
}
}
@@ -0,0 +1,264 @@
/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using NUnit.Framework;
using Opc.Ua.Client;
using Opc.Ua.Client.TestFramework;
using Opc.Ua.Configuration;
using Opc.Ua.Server.Hosting;
using Opc.Ua.Server.TestFramework;
using Opc.Ua.Tests;
namespace Opc.Ua.Server.Tests.Hosting
{
/// <summary>
/// Exercises the default hosted server through real reverse-connect discovery and session channels.
/// </summary>
[TestFixture]
[Category("Hosting")]
[Category("ReverseConnect")]
[NonParallelizable]
public sealed class HostedReverseConnectIntegrationTests
{
[TestCase("options")]
[TestCase("file")]
[TestCase("stream")]
public async Task DefaultHostedServerSupportsReverseSessionsWithDiHooksAsync(string configurationSource)
{
string root = Path.Combine(Path.GetTempPath(), "opc4410", Guid.NewGuid().ToString("N")[..8]);
Directory.CreateDirectory(root);
ITelemetryContext telemetry = NUnitTelemetryContext.Create(isServer: true);
var client = new ClientFixture(telemetry);
Stream configurationStream = null;
using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(60));
try
{
await client.LoadClientConfigurationAsync(Path.Combine(root, "client")).ConfigureAwait(false);
client.Config.ClientConfiguration!.ReverseConnect = new ReverseConnectClientConfiguration
{
HoldTime = 5000
};
var clientUrl = new Uri(
"opc.tcp://localhost:" +
ServerFixtureUtils.GetNextFreeIPPort().ToString(CultureInfo.InvariantCulture));
client.ReverseConnectManager.AddEndpoint(clientUrl, client.Config);
await client.ReverseConnectManager.StartServiceAsync(client.Config).ConfigureAwait(false);
string serverUrl = "opc.tcp://localhost:" +
ServerFixtureUtils.GetNextFreeIPPort().ToString(CultureInfo.InvariantCulture) + "/HostedReverse";
var started = new TaskCompletionSource<IServerContext>(
TaskCreationOptions.RunContinuationsAsynchronously);
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton(telemetry);
IOpcUaBuilder builder = services.AddOpcUa();
IOpcUaServerBuilder serverBuilder;
if (configurationSource == "options")
{
serverBuilder = builder.AddServer(options =>
{
options.ApplicationName = kApplicationName;
options.ApplicationUri = kApplicationUri;
options.ProductUri = kProductUri;
options.PkiRoot = Path.Combine(root, "server");
options.EndpointUrls.Add(serverUrl);
options.IncludeSignAndEncryptPolicies = false;
options.IncludeUnsecurePolicyNone = true;
options.ReverseConnect = new ServerReverseConnectOptions
{
ConnectIntervalMs = 100,
ConnectTimeoutMs = 10000
};
options.ReverseConnect.Clients.Add(new ServerReverseConnectClientOptions
{
EndpointUrl = clientUrl.AbsoluteUri,
Timeout = 10000,
MaxSessionCount = 1
});
});
}
else
{
string path = await WriteConfigurationAsync(
root, serverUrl, clientUrl, telemetry, cancellation.Token).ConfigureAwait(false);
if (configurationSource == "file")
{
serverBuilder = builder.AddServer(path);
}
else
{
configurationStream = File.OpenRead(path);
serverBuilder = builder.AddServer(configurationStream);
}
}
serverBuilder
.ConfigureRoles(_ => { })
.ConfigureServerProperties(properties => properties.ManufacturerName = "Hosting integration test")
.AddStartupTask((_, context, _) =>
{
started.TrySetResult(context);
return default;
});
ServiceProvider provider = services.BuildServiceProvider();
var hosted = (BackgroundService)provider.GetServices<IHostedService>().Single();
try
{
await hosted.StartAsync(cancellation.Token).ConfigureAwait(false);
Task completed = await Task.WhenAny(
started.Task,
hosted.ExecuteTask!,
Task.Delay(Timeout.Infinite, cancellation.Token)).ConfigureAwait(false);
cancellation.Token.ThrowIfCancellationRequested();
if (completed == hosted.ExecuteTask)
{
await hosted.ExecuteTask.ConfigureAwait(false);
Assert.Fail("The hosted server stopped before completing startup.");
}
IServerContext context = await started.Task.ConfigureAwait(false);
Assert.That(context.CurrentState, Is.EqualTo(ServerState.Running));
Assert.That(context.FindNodeManagers<IDiagnosticsNodeManager>().Count(), Is.EqualTo(1));
var expectedEndpoint = new Uri(Utils.ReplaceLocalhost(serverUrl));
ITransportWaitingConnection discoveryConnection =
await client.ReverseConnectManager.WaitForConnectionAsync(
expectedEndpoint, null, cancellation.Token).ConfigureAwait(false);
EndpointDescription description = await CoreClientUtils.SelectEndpointAsync(
client.Config, discoveryConnection, false, 10000, telemetry).ConfigureAwait(false);
var endpoint = new ConfiguredEndpoint(
null, description, EndpointConfiguration.Create(client.Config));
ITransportWaitingConnection sessionConnection =
await client.ReverseConnectManager.WaitForConnectionAsync(
expectedEndpoint, null, cancellation.Token).ConfigureAwait(false);
using Client.ISession session = await client.SessionFactory.CreateAsync(
client.Config,
sessionConnection,
endpoint,
false,
false,
"Hosted reverse-connect integration",
30000,
new UserIdentity(),
default,
cancellation.Token).ConfigureAwait(false);
try
{
DataValue value = await session.ReadValueAsync(
VariableIds.Server_ServerStatus_BuildInfo_ProductName,
cancellation.Token).ConfigureAwait(false);
Assert.That(value.StatusCode, Is.EqualTo(StatusCodes.Good));
Assert.That(value.WrappedValue.TryGetValue(out string productName), Is.True);
Assert.That(productName, Is.EqualTo(kApplicationName));
}
finally
{
await session.CloseAsync(CancellationToken.None).ConfigureAwait(false);
}
}
finally
{
cancellation.Cancel();
using var shutdown = new CancellationTokenSource(TimeSpan.FromSeconds(15));
try
{
await hosted.StopAsync(shutdown.Token).ConfigureAwait(false);
}
finally
{
await provider.DisposeAsync().ConfigureAwait(false);
}
}
}
finally
{
cancellation.Cancel();
await client.DisposeAsync().ConfigureAwait(false);
configurationStream?.Dispose();
Directory.Delete(root, recursive: true);
}
}
private static async Task<string> WriteConfigurationAsync(
string root,
string serverUrl,
Uri clientUrl,
ITelemetryContext telemetry,
CancellationToken ct)
{
var application = new ApplicationInstance(telemetry) { ApplicationName = kApplicationName };
try
{
string pkiRoot = Path.Combine(root, "server");
ApplicationConfiguration configuration = await application
.Build(kApplicationUri, kProductUri)
.AsServer([serverUrl])
.AddUnsecurePolicyNone()
.SetReverseConnect(new ReverseConnectServerConfiguration
{
ConnectInterval = 100,
ConnectTimeout = 10000,
Clients =
[
new ReverseConnectClient
{
EndpointUrl = clientUrl.AbsoluteUri,
Timeout = 10000,
MaxSessionCount = 1,
Enabled = true
}
]
})
.AddSecurityConfiguration(
ApplicationConfigurationBuilder.CreateDefaultApplicationCertificates(
"CN=" + kApplicationName, CertificateStoreType.Directory, pkiRoot),
pkiRoot)
.CreateAsync(ct).ConfigureAwait(false);
string path = Path.Combine(root, "server.xml");
configuration.SaveToFile(path);
return path;
}
finally
{
await application.DisposeAsync().ConfigureAwait(false);
}
}
private const string kApplicationName = "HostedReverse";
private const string kApplicationUri = "urn:opcfoundation:test:hosted-reverse";
private const string kProductUri = "urn:opcfoundation:test:hosted-reverse:product";
}
}
@@ -62,7 +62,7 @@ namespace Opc.Ua.Server.Tests.Hosting
/// <summary>
/// Directly exercises <see cref="OpcUaServerHostedService"/> paths that
/// are not reached by the broader fluent-API hosting tests: the
/// constructor's null-argument guards, early historian and post-start
/// constructor's null-argument guards, early historian and alias
/// alias registry wiring on a plain
/// (non dependency-injection-aware) <see cref="StandardServer"/>, and
/// the matched-authenticator branches of <c>HasMatchingAuthenticator</c>
@@ -90,7 +90,6 @@ namespace Opc.Ua.Server.Tests.Hosting
[],
[],
[],
[],
Mock.Of<IServiceProvider>(),
Mock.Of<IOpcUaServerFactory>(),
new HostedNodeManagerLifecycle(),
@@ -114,7 +113,6 @@ namespace Opc.Ua.Server.Tests.Hosting
[],
[],
[],
[],
Mock.Of<IServiceProvider>(),
Mock.Of<IOpcUaServerFactory>(),
new HostedNodeManagerLifecycle(),
@@ -147,7 +145,7 @@ namespace Opc.Ua.Server.Tests.Hosting
services.AddLogging(builder => builder.AddProvider(loggerProvider));
services.AddSingleton(continuationStore);
services.AddOpcUa()
.AddServer<RegistryCaptureServer>(o => ConfigureHostedOptions(o, "PostStartRegistries"))
.AddServer<RegistryCaptureServer>(o => ConfigureHostedOptions(o, "EarlyRegistries"))
.AddHistorian(historian.Object)
.AddAliasNameStore(directStore.Object)
.AddAliasNameStoreRegistry(sourceRegistry.Object);
@@ -158,7 +156,7 @@ namespace Opc.Ua.Server.Tests.Hosting
() => RegistryCaptureServer.StartedServer != null,
TimeSpan.FromSeconds(60)).ConfigureAwait(false),
Is.True,
"Hosted service post-start registry wiring did not complete.");
"Hosted service registry wiring did not complete.");
IServerInternal server = RegistryCaptureServer.StartedServer ??
throw new InvalidOperationException("The server did not start.");
@@ -166,9 +164,6 @@ namespace Opc.Ua.Server.Tests.Hosting
var historianRegistryProvider = (IHistorianRegistryProvider)server;
var aliasRegistryProvider = (IAliasNameStoreRegistryProvider)server;
// OnServerStarted publishes StartedServer before the hosted service completes
// post-start alias registration. The historian is already present by
// MasterNodeManager startup.
Assert.That(
await WaitForAsync(
() =>
@@ -383,9 +378,8 @@ namespace Opc.Ua.Server.Tests.Hosting
/// <see cref="DependencyInjectionStandardServer"/>) whose
/// <c>CurrentInstance</c> is the stock <c>ServerInternalData</c>,
/// which implements <see cref="IHistorianRegistryProvider"/> and
/// <see cref="IAliasNameStoreRegistryProvider"/> -- required to
/// reach the post-start alias-registration loops, which explicitly
/// skip <see cref="DependencyInjectionStandardServer"/>.
/// <see cref="IAliasNameStoreRegistryProvider"/>. The hosted service
/// stages both registries before this server's node managers start.
/// </summary>
[SuppressMessage(
"Performance",
File diff suppressed because it is too large Load Diff
@@ -255,10 +255,10 @@ namespace Opc.Ua.Server.Tests.Hosting
}
/// <summary>
/// Verifies that alias-name registration is applied when node managers have started.
/// Verifies that alias-name registration is applied before node managers start.
/// </summary>
[Test]
public void AddAliasNamesRegistersAtNodeManagerStarted()
public async Task AddAliasNamesRegistersBeforeNodeManagerStartupAsync()
{
Mock<IAliasNameStore> aliasStore = new(MockBehavior.Strict);
aliasStore.SetupGet(s => s.RootCategories).Returns([]);
@@ -272,7 +272,8 @@ namespace Opc.Ua.Server.Tests.Hosting
.SetupGet(s => s.AliasNameStoreRegistry)
.Returns(aliasRegistry);
InvokeProtected(server, "OnNodeManagerStarted", serverInternal.Object);
await new OpcUaServerAliasNameStartupTask(sp)
.OnServerStartingAsync(serverInternal.Object).ConfigureAwait(false);
Assert.That(aliasRegistry.Stores, Does.Contain(aliasStore.Object));
}
@@ -357,10 +358,10 @@ namespace Opc.Ua.Server.Tests.Hosting
}
/// <summary>
/// Verifies that registered alias stores are copied into the server when node managers have started.
/// Verifies that registered alias stores are copied into the server before node managers start.
/// </summary>
[Test]
public void AddAliasNameStoreRegistryCopiesStoresAtNodeManagerStarted()
public async Task AddAliasNameStoreRegistryCopiesStoresBeforeNodeManagerStartupAsync()
{
Mock<IAliasNameStore> aliasStore = new(MockBehavior.Strict);
aliasStore.SetupGet(s => s.RootCategories).Returns([]);
@@ -379,7 +380,8 @@ namespace Opc.Ua.Server.Tests.Hosting
.SetupGet(s => s.AliasNameStoreRegistry)
.Returns(targetRegistry);
InvokeProtected(server, "OnNodeManagerStarted", serverInternal.Object);
await new OpcUaServerAliasNameStartupTask(sp)
.OnServerStartingAsync(serverInternal.Object).ConfigureAwait(false);
Assert.That(targetRegistry.Stores, Does.Contain(aliasStore.Object));
}
@@ -245,5 +245,73 @@ namespace Opc.Ua.Server.Tests
Is.EqualTo(/*lang=json,strict*/ "{\"t\":[[\"de-DE\",\"Hallo User\"],[\"en-US\",\"Hello User\"]]}"));
Assert.That(resultText.Locale, Is.EqualTo("mul"));
}
[Test]
public void TranslateUsesRegisteredTextAndLocaleInsteadOfFallback()
{
var configuration = new ApplicationConfiguration(NUnitTelemetryContext.Create());
using var resources = new ResourceManager(configuration);
resources.Add("greeting", "de-DE", "Hallo {0}");
LocalizedText translated = resources.Translate(["de-DE"], "greeting", "Hello {0}", "User");
Assert.That(translated.Text, Is.EqualTo("Hallo User"));
Assert.That(translated.Locale, Is.EqualTo("de-DE"));
Assert.That(translated.TranslationInfo.Key, Is.EqualTo("greeting"));
Assert.That(translated.TranslationInfo.Text, Is.EqualTo("Hallo {0}"));
}
[TestCase(null)]
[TestCase(Opc.Ua.Namespaces.OpcUa)]
public void TranslateStandardStatusUsesRegisteredTranslation(string namespaceUri)
{
var configuration = new ApplicationConfiguration(NUnitTelemetryContext.Create());
using var resources = new ResourceManager(configuration);
resources.LoadDefaultText();
resources.Add(StatusCodes.BadTimeout, "de-DE", "Zeitlimit abgelaufen");
var input = new ServiceResult(
StatusCodes.BadTimeout,
new System.Xml.XmlQualifiedName("BadTimeout", namespaceUri),
LocalizedText.Null);
ServiceResult translated = resources.Translate(["de-DE"], input);
Assert.That(translated.StatusCode, Is.EqualTo(StatusCodes.BadTimeout));
Assert.That(translated.LocalizedText.Text, Is.EqualTo("Zeitlimit abgelaufen"));
Assert.That(translated.LocalizedText.Locale, Is.EqualTo("de-DE"));
Assert.That(resources.Translate(["en-US"], input).LocalizedText.Text, Is.EqualTo("BadTimeout"));
}
[Test]
public void TranslateStandardStatusWithoutMappingRetainsSymbolicId()
{
var configuration = new ApplicationConfiguration(NUnitTelemetryContext.Create());
using var resources = new ResourceManager(configuration);
ServiceResult translated = resources.Translate(["de-DE"], new ServiceResult(StatusCodes.BadTimeout));
Assert.That(translated.LocalizedText.Text, Is.EqualTo("BadTimeout"));
Assert.That(translated.StatusCode, Is.EqualTo(StatusCodes.BadTimeout));
}
[TestCase("BadTimeout", "urn:custom-errors")]
[TestCase("CustomTimeout", Opc.Ua.Namespaces.OpcUa)]
public void TranslateCustomSymbolicIdDoesNotUseStandardStatusTranslation(string name, string namespaceUri)
{
var configuration = new ApplicationConfiguration(NUnitTelemetryContext.Create());
using var resources = new ResourceManager(configuration);
resources.LoadDefaultText();
resources.Add(StatusCodes.BadTimeout, "de-DE", "Zeitlimit abgelaufen");
var input = new ServiceResult(
StatusCodes.BadTimeout,
new System.Xml.XmlQualifiedName(name, namespaceUri),
LocalizedText.Null);
ServiceResult translated = resources.Translate(["de-DE"], input);
Assert.That(translated.LocalizedText.Text, Is.EqualTo(name));
Assert.That(translated.NamespaceUri, Is.EqualTo(namespaceUri));
Assert.That(translated.SymbolicId, Is.EqualTo(name));
}
}
}
@@ -0,0 +1,134 @@
/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using NUnit.Framework;
using Opc.Ua.Tests;
namespace Opc.Ua.Server.Tests
{
/// <summary>
/// Exercises callbacks already queued when their reverse-connect timer is disposed.
/// </summary>
[TestFixture]
[Category("ReverseConnect")]
[Parallelizable]
public sealed class ReverseConnectServerTimerTests
{
[TestCase(false)]
[TestCase(true)]
public async Task QueuedCallbackAfterShutdownCannotConnectOrRearmAsync(bool dispose)
{
var scheduler = new QueuedTimerScheduler();
using var server = new ReverseConnectServer(NUnitTelemetryContext.Create(), scheduler.Clock.Object);
server.AddReverseConnection(s_clientUrl);
Action queuedCallback = scheduler.Callbacks[0];
if (dispose)
{
server.Dispose();
}
else
{
await server.StopAsync(CancellationToken.None).ConfigureAwait(false);
}
queuedCallback();
Assert.That(scheduler.Timers, Has.Count.EqualTo(1));
scheduler.Timers[0].Verify(timer => timer.Dispose(), Times.Once);
Assert.That(server.GetReverseConnections()[s_clientUrl].LastState, Is.EqualTo(ReverseConnectState.Closed));
}
[Test]
public void QueuedCallbackFromRemovedTimerDoesNotReplaceCurrentTimer()
{
var scheduler = new QueuedTimerScheduler();
using var server = new ReverseConnectServer(NUnitTelemetryContext.Create(), scheduler.Clock.Object);
server.AddReverseConnection(s_clientUrl, enabled: false);
Action queuedCallback = scheduler.Callbacks[0];
Assert.That(server.RemoveReverseConnection(s_clientUrl), Is.True);
server.AddReverseConnection(s_clientUrl, enabled: false);
queuedCallback();
Assert.That(scheduler.Timers, Has.Count.EqualTo(2));
scheduler.Timers[0].Verify(timer => timer.Dispose(), Times.Once);
scheduler.Timers[1].Verify(timer => timer.Dispose(), Times.Never);
}
[Test]
public void ActiveCallbackSchedulesTheNextAttempt()
{
var scheduler = new QueuedTimerScheduler();
using var server = new ReverseConnectServer(NUnitTelemetryContext.Create(), scheduler.Clock.Object);
server.AddReverseConnection(s_clientUrl, enabled: false);
scheduler.Callbacks[0]();
Assert.That(scheduler.Timers, Has.Count.EqualTo(2));
scheduler.Timers[0].Verify(timer => timer.Dispose(), Times.Once);
scheduler.Timers[1].Verify(timer => timer.Dispose(), Times.Never);
Assert.That(scheduler.DueTimes[0], Is.EqualTo(TimeSpan.FromSeconds(1)));
Assert.That(scheduler.DueTimes[1],
Is.EqualTo(TimeSpan.FromMilliseconds(ReverseConnectServer.DefaultReverseConnectInterval)));
}
private sealed class QueuedTimerScheduler
{
public QueuedTimerScheduler()
{
Clock.Setup(clock => clock.CreateTimer(
It.IsAny<TimerCallback>(),
It.IsAny<object>(),
It.IsAny<TimeSpan>(),
It.IsAny<TimeSpan>()))
.Returns((TimerCallback callback, object state, TimeSpan dueTime, TimeSpan period) =>
{
Assert.That(period, Is.EqualTo(Timeout.InfiniteTimeSpan));
var timer = new Mock<ITimer>();
Timers.Add(timer);
Callbacks.Add(() => callback(state));
DueTimes.Add(dueTime);
return timer.Object;
});
}
public Mock<TimeProvider> Clock { get; } = new();
public List<Mock<ITimer>> Timers { get; } = [];
public List<Action> Callbacks { get; } = [];
public List<TimeSpan> DueTimes { get; } = [];
}
private static readonly Uri s_clientUrl = new("opc.tcp://localhost:4840/queued-timer");
}
}
@@ -27,11 +27,11 @@
<TargetFrameworks Condition="'$(_StressTestSupportedTarget)' == 'true' AND '$(CustomTestTarget)' != ''">$(TestsTargetFrameworks)</TargetFrameworks>
<!--
For incompatible matrix entries (net48 / net472 / netstandard2.0) the
project compiles against net8.0 as a placeholder that contains no test
project follows the matrix framework as a placeholder that contains no test
sources. The matrix step still runs dotnet test against the resulting
assembly; NUnit discovers zero tests and exits cleanly.
-->
<TargetFrameworks Condition="'$(_StressTestSupportedTarget)' != 'true'">net8.0</TargetFrameworks>
<TargetFrameworks Condition="'$(_StressTestSupportedTarget)' != 'true'">$(TestsTargetFrameworks)</TargetFrameworks>
<EnableDefaultCompileItems Condition="'$(_StressTestSupportedTarget)' != 'true'">false</EnableDefaultCompileItems>
<!--
The placeholder assembly compiled for net48/net472/netstandard2.0
@@ -98,14 +98,14 @@ namespace Opc.Ua.Types.Tests.State
}
[Test]
public void DefaultNextAsyncCompletesSynchronously()
public async Task DefaultNextAsyncCompletesSynchronouslyAsync()
{
using NodeBrowser browser = CreateBrowser(CreateReferences(1));
ValueTask<IReference?> pending = browser.NextAsync();
Assert.That(pending.IsCompletedSuccessfully, Is.True);
Assert.That(pending.Result, Is.Not.Null);
Assert.That(await pending.ConfigureAwait(false), Is.Not.Null);
}
[Test]
@@ -0,0 +1,312 @@
/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using NUnit.Framework;
using Opc.Ua.Server;
using Opc.Ua.Tests;
using Opc.Ua.WotCon.Server;
using Opc.Ua.WotCon.Server.Materialization;
using Opc.Ua.WotCon.Server.Registry;
using Opc.Ua.XRegistry;
namespace Opc.Ua.WotCon.Tests
{
[TestFixture]
[Category("WotCon")]
public sealed class WotRegistryProjectionOrderingTests
{
[Test]
public async Task ThingModelMetadataIsVisibleWhenMutationsCompleteWithQueuedReconciliation()
{
await using var harness = new ProjectionHarness();
await harness.AttachAsync().ConfigureAwait(false);
GroupState group = await harness.CreateThingModelGroupAsync().ConfigureAwait(false);
await harness.WaitUntilReconciliationBlockedAsync().ConfigureAwait(false);
ThingModelFileState v1 = await harness.CreateVersionAsync(group, "v1").ConfigureAwait(false);
await harness.UploadAsync(v1, "urn:model-titles-first", "1.0.0").ConfigureAwait(false);
ThingModelFileState v2 = await harness.CreateVersionAsync(group, "v2").ConfigureAwait(false);
await harness.UploadAsync(v2, "urn:model-titles-second", "2.0.0").ConfigureAwait(false);
await harness.SetDefaultVersionAsync(v2, "v2").ConfigureAwait(false);
WotResource stored = harness.Registry.Current.FindResource(
WotRegistryGroups.ThingModels,
"model-titles")!;
string v1Title = v1.ModelTitle!.Value;
string v1ModelVersion = v1.ModelVersion!.Value;
string v2Title = v2.ModelTitle!.Value;
string v2ModelVersion = v2.ModelVersion!.Value;
bool v1IsDefault = v1.IsDefault!.Value;
bool v2IsDefault = v2.IsDefault!.Value;
await harness.DrainReconciliationAsync().ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(stored.DefaultVersionId, Is.EqualTo("v2"));
Assert.That(stored.Title, Is.EqualTo("urn:model-titles-second"));
Assert.That(stored.FindVersion("v2")!.Title, Is.EqualTo("urn:model-titles-second"));
Assert.That(stored.FindVersion("v2")!.ModelVersion, Is.EqualTo("2.0.0"));
Assert.That(v1Title, Is.EqualTo("urn:model-titles-first"));
Assert.That(v1ModelVersion, Is.EqualTo("1.0.0"));
Assert.That(v2Title, Is.EqualTo("urn:model-titles-second"));
Assert.That(v2ModelVersion, Is.EqualTo("2.0.0"));
Assert.That(v1IsDefault, Is.False);
Assert.That(v2IsDefault, Is.True);
Assert.That(v1.ModelTitle.Value, Is.EqualTo("urn:model-titles-first"));
Assert.That(v1.ModelVersion.Value, Is.EqualTo("1.0.0"));
Assert.That(v2.ModelTitle.Value, Is.EqualTo("urn:model-titles-second"));
Assert.That(v2.ModelVersion.Value, Is.EqualTo("2.0.0"));
Assert.That(v1.IsDefault.Value, Is.False);
Assert.That(v2.IsDefault.Value, Is.True);
});
}
[Test]
public async Task ThingModelUploadPublishesVersionMetadataBeforeReturning()
{
await using var harness = new ProjectionHarness();
await harness.AttachAsync().ConfigureAwait(false);
GroupState group = await harness.CreateThingModelGroupAsync().ConfigureAwait(false);
await harness.WaitUntilReconciliationBlockedAsync().ConfigureAwait(false);
ThingModelFileState v1 = await harness.CreateVersionAsync(group, "v1").ConfigureAwait(false);
await harness.UploadAsync(v1, "urn:model-titles-first", "1.0.0").ConfigureAwait(false);
ThingModelFileState v2 = await harness.CreateVersionAsync(group, "v2").ConfigureAwait(false);
await harness.UploadAsync(v2, "urn:model-titles-second", "2.0.0").ConfigureAwait(false);
WotResource stored = harness.Registry.Current.FindResource(
WotRegistryGroups.ThingModels,
"model-titles")!;
Assert.Multiple(() =>
{
Assert.That(stored.DefaultVersionId, Is.EqualTo("v1"));
Assert.That(stored.Title, Is.EqualTo("urn:model-titles-first"));
Assert.That(stored.FindVersion("v2")!.Title, Is.EqualTo("urn:model-titles-second"));
Assert.That(stored.FindVersion("v2")!.ModelVersion, Is.EqualTo("2.0.0"));
Assert.That(v1.ModelTitle!.Value, Is.EqualTo("urn:model-titles-first"));
Assert.That(v1.ModelVersion!.Value, Is.EqualTo("1.0.0"));
Assert.That(v1.IsDefault!.Value, Is.True);
Assert.That(v2.ModelTitle!.Value, Is.EqualTo("urn:model-titles-second"));
Assert.That(v2.ModelVersion!.Value, Is.EqualTo("2.0.0"));
Assert.That(v2.IsDefault!.Value, Is.False);
});
}
[Test]
public async Task SetEnabledPublishesStateBeforeReturning()
{
await using var harness = new ProjectionHarness();
await harness.AttachAsync().ConfigureAwait(false);
GroupState group = await harness.CreateThingModelGroupAsync().ConfigureAwait(false);
await harness.WaitUntilReconciliationBlockedAsync().ConfigureAwait(false);
ThingModelFileState version = await harness.CreateVersionAsync(group, "v1").ConfigureAwait(false);
await harness.SetEnabledAsync(version, false).ConfigureAwait(false);
bool enabledAfterDisable = version.Enabled!.Value;
await harness.SetEnabledAsync(version, true).ConfigureAwait(false);
bool enabledAfterEnable = version.Enabled.Value;
Assert.Multiple(() =>
{
Assert.That(enabledAfterDisable, Is.False);
Assert.That(enabledAfterEnable, Is.True);
});
}
private sealed class ProjectionHarness : IAsyncDisposable
{
public ProjectionHarness()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
var namespaces = new NamespaceTable();
namespaces.Append(Namespaces.WotCon);
namespaces.Append(XRegistryWellKnown.XRegistryNamespaceUri);
var server = new Mock<IServerInternal>();
server.SetupGet(value => value.NamespaceUris).Returns(namespaces);
server.SetupGet(value => value.ServerUris).Returns(new StringTable());
server.SetupGet(value => value.TypeTree).Returns(new TypeTable(namespaces));
server.SetupGet(value => value.Factory).Returns(EncodeableFactory.Create());
server.SetupGet(value => value.Telemetry).Returns(telemetry);
server.SetupGet(value => value.NodeManager).Returns(Mock.Of<IMasterNodeManager>());
m_monitoredItemQueueFactory = new MonitoredItemQueueFactory(telemetry);
server.SetupGet(value => value.MonitoredItemQueueFactory).Returns(m_monitoredItemQueueFactory);
server.SetupGet(value => value.DefaultSystemContext).Returns(new ServerSystemContext(server.Object));
Registry = new WotRegistryService();
m_coordinator = new WotMaterializationCoordinator(Registry, Mock.Of<IWotProjectionHost>());
var options = new WotRegistryServerOptions { AutoRefresh = false };
m_manager = new WotRegistryNodeManager(
server.Object,
new ApplicationConfiguration { ServerConfiguration = new ServerConfiguration() },
options,
Registry,
m_coordinator);
m_projection = new WotRegistryProjection(m_manager, Registry, options);
m_queue = new WotRegistryReconcileQueue(async change =>
{
m_entered.TrySetResult(true);
await m_release.Task.ConfigureAwait(false);
await m_projection.ReconcileAsync(
change.Previous,
change.Current,
CancellationToken.None).ConfigureAwait(false);
});
Registry.Changed += OnRegistryChanged;
m_registryNode = new RegistryState(null);
m_registryNode.Create(
m_manager.SystemContext,
new NodeId("registry", namespaces.GetIndexOrAppend(Namespaces.WotCon)),
new QualifiedName("Registry", namespaces.GetIndexOrAppend(Namespaces.WotCon)),
new LocalizedText("Registry"),
assignNodeIds: false);
m_registryNode.AddCreateGroup(m_manager.SystemContext);
}
public WotRegistryService Registry { get; }
public async ValueTask AttachAsync()
{
await Registry.InitializeAsync().ConfigureAwait(false);
await m_projection.AttachAsync(m_registryNode, CancellationToken.None).ConfigureAwait(false);
}
public async ValueTask<GroupState> CreateThingModelGroupAsync()
{
await CallAsync(
m_registryNode.CreateGroup!,
[new Variant(WotRegistryGroups.ThingModels)]).ConfigureAwait(false);
var children = new List<BaseInstanceState>();
m_registryNode.GetChildren(m_manager.SystemContext, children);
return children.Find(child => child is ThingModelGroupState) as GroupState
?? throw new InvalidOperationException("The Thing Model group was not projected.");
}
public Task<bool> WaitUntilReconciliationBlockedAsync()
{
return m_entered.Task;
}
public async ValueTask DrainReconciliationAsync()
{
m_release.TrySetResult(true);
await m_queue.WhenIdleAsync().ConfigureAwait(false);
}
public async ValueTask<ThingModelFileState> CreateVersionAsync(GroupState group, string versionId)
{
await CallAsync(
group.CreateResource!,
[new Variant("model-titles"), new Variant(versionId), new Variant(false)]).ConfigureAwait(false);
WotResource resource = Registry.Current.FindResource(WotRegistryGroups.ThingModels, "model-titles")!;
return (ThingModelFileState)m_projection.EventSourceFor($"{resource.Xid}/versions/{versionId}");
}
public async ValueTask UploadAsync(ThingModelFileState version, string title, string modelVersion)
{
uint handle = 0;
ServiceResult opened = version.Open!.OnCall!(
m_manager.SystemContext, version.Open, version.NodeId, 6, ref handle);
Assert.That(ServiceResult.IsGood(opened), Is.True, opened.ToString());
ByteString content = ByteString.From(Encoding.UTF8.GetBytes(
"{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\"," +
"\"@type\":\"tm:ThingModel\",\"id\":\"urn:model-titles\"," +
"\"title\":\"" + title + "\",\"version\":{\"model\":\"" + modelVersion + "\"}}"));
ServiceResult written = version.Write!.OnCall!(
m_manager.SystemContext, version.Write, version.NodeId, handle, content);
Assert.That(ServiceResult.IsGood(written), Is.True, written.ToString());
CloseMethodStateResult closed = await version.Close!.OnCallAsync!(
m_manager.SystemContext,
version.Close,
version.NodeId,
handle,
CancellationToken.None).ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(closed.ServiceResult), Is.True, closed.ServiceResult.ToString());
}
public ValueTask SetDefaultVersionAsync(ThingModelFileState version, string versionId)
{
return CallAsync(version.SetDefaultVersion!, [new Variant(versionId), new Variant(0u)]);
}
public ValueTask SetEnabledAsync(ThingModelFileState version, bool enabled)
{
return CallAsync(version.SetEnabled!, [new Variant(enabled), new Variant(0u)]);
}
public async ValueTask DisposeAsync()
{
Registry.Changed -= OnRegistryChanged;
m_release.TrySetResult(true);
await m_queue.CompleteAsync().ConfigureAwait(false);
m_queue.Dispose();
m_projection.Dispose();
m_manager.Dispose();
m_coordinator.Dispose();
Registry.Dispose();
m_monitoredItemQueueFactory.Dispose();
}
private async ValueTask CallAsync(MethodState method, ArrayOf<Variant> input)
{
NodeState parent = method.Parent ??
throw new InvalidOperationException("A projected method must have a parent.");
ServiceResult result = await method.OnCallMethod2Async!(
m_manager.SystemContext,
method,
parent.NodeId,
input,
[],
CancellationToken.None).ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(result), Is.True, result.ToString());
}
private void OnRegistryChanged(object? sender, WotRegistryChangedEventArgs change)
{
m_queue.Enqueue(change);
}
private readonly WotRegistryNodeManager m_manager;
private readonly WotMaterializationCoordinator m_coordinator;
private readonly WotRegistryProjection m_projection;
private readonly WotRegistryReconcileQueue m_queue;
private readonly RegistryState m_registryNode;
private readonly MonitoredItemQueueFactory m_monitoredItemQueueFactory;
private readonly TaskCompletionSource<bool> m_entered = new(
TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource<bool> m_release = new(
TaskCreationOptions.RunContinuationsAsynchronously);
}
}
}