Files
Marc Schier b3c1e60f46 AMB: add bounded strict reads and configurable sample hosting (#4803)
# Description

Extract the AMB layer from #4462 onto the client/server predecessor
#4802, then address the four review findings in
`6428686ae45724510bd6bd00619f4992f3d36e46`.

The initial extraction (`95b0caa1ac30d1fdacaf7a780f19c5f97e6fd02d`)
contained exactly 19 paths: 12 AMB client files, four AMB test files,
two sample entry points and `docs/AMB.md`. Its code/tests matched the
frozen source
`5b03e9afa492cdb36049ee52ae844f630f89d8db...390c4a7a51b71da40caec2d22ef74518bbe670ce`;
only the two agreed UaLens-only documentation passages were omitted. The
review follow-up intentionally changes that extracted implementation and
adds the related factory/registration tests and package documentation.
No UaLens, Machinery, Pumps, Positioning, native-rendering, build or CI
changes are introduced by this layer.

The AMB client provides opt-in bounded traversal, strict response/type
validation and original `AssetReadValue` evidence while preserving
tolerant defaults. The samples expose validated host, port, PKI root,
certificate auto-accept and bounded-runtime options, with unchanged
standalone defaults.

The review corrections are:

- Accept header-validated empty BrowseNext release responses required by
Part 4 §5.9.3.2, as well as the stack's single-result response. Invalid
cleanup results still preserve the original failure.
- Keep event observation tolerant of denied/limited maintenance-class
discovery without caching incomplete classes. Subsequent strict reads
retry discovery and still report errors.
- Accept typed-null ExtensionObjects only in the nullable
structure-field checks, preserving original evidence without weakening
scalar validation.
- Pass optional immutable limits through `AddAssetManagementClient`, the
registered factory/delegate and directly constructed factories. Preserve
existing overloads and tolerant behavior when options are omitted;
reject invalid limits before opening a session.

### Review regression evidence

| Requirement | Evidence |
| --- | --- |
| Accept empty cleanup responses without masking cancellation or limits
| `OneBeyondReferenceLimitReleasesAcquiredContinuationAsync` and
`CancellationWhileAwaitingNextReleasesTheSubmittedContinuationAsync`,
each with empty and single-result responses; malformed responses remain
covered |
| Preserve observation fallback and do not cache incomplete classes |
`ObservationFallsBackWithoutCachingIncompleteClassesAsync`: both
observation APIs, denied browsing and exhausted budget, correct
filter/event evidence, and a subsequent strict rediscovery |
| Preserve typed-null structure evidence without weakening scalar checks
| `ContextAndEntryReadsKeepSourceAndNullStructureEvidenceAsync`,
`MaintenanceTypedNullStructuresKeepOriginalEvidenceAsync`,
`ScalarHealthRejectsTypedNullStructuresAsync`, plus
wrong-non-null-structure rejection tests |
| Apply configured hosted limits while retaining tolerant defaults |
`HostedClientOptionsReachClientsAsync`: registered factory, registered
delegate and direct factory, singleton and builder registration, actual
one-reference limit and read evidence;
`InvalidHostedLimitsAreRejectedBeforeOpeningASession` |

### Validation

All commands below ran in this layer's worktree with the existing pinned
.NET SDK 10.0.401. No SDK installation, global.json change,
registry/path-policy change, test suppression or analyzer suppression
was made. The short owned NUnit directory `D:\a61r` and its generated
certificates/state were removed afterward.

**Latest full AMB suite: Passed: 285, Failed: 0, Skipped: 0 on each
framework.** Both builds emitted no compiler/analyzer diagnostics. TRX
files confirm the regression cases executed on both TFMs.

```powershell
& 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' test 'tests\Opc.Ua.AMB.Tests\Opc.Ua.AMB.Tests.csproj' --no-restore --configuration Release --framework net10.0 -p:CustomTestTarget=net10.0 --nologo --verbosity minimal --logger 'trx;LogFileName=amb-feedback-net10.trx' --results-directory 'C:\Users\mschier\.copilot\session-state\61d7ed73-c89e-406a-a049-1d8ba9681308\files\TestResults\feedback-net10' -- 'NUnit.WorkDirectory=D:\a61r'
& 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' test 'tests\Opc.Ua.AMB.Tests\Opc.Ua.AMB.Tests.csproj' --configuration Release --framework net48 -p:CustomTestTarget=net48 --nologo --verbosity minimal --logger 'trx;LogFileName=amb-feedback-net48.trx' --results-directory 'C:\Users\mschier\.copilot\session-state\61d7ed73-c89e-406a-a049-1d8ba9681308\files\TestResults\feedback-net48' -- 'NUnit.WorkDirectory=D:\a61r'
```

**Focused regression run: Passed: 127, Failed: 0, Skipped: 0.** Before
the production fixes, the focused repro executed 112 cases with 16
failures reproducing all four reported defects. Additional
registration/validation cases were included in the final run.

```powershell
& 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' test 'tests\Opc.Ua.AMB.Tests\Opc.Ua.AMB.Tests.csproj' --no-restore --configuration Release --framework net10.0 -p:CustomTestTarget=net10.0 --nologo --verbosity minimal --filter 'FullyQualifiedName~AmbBoundedClientTests|FullyQualifiedName~AmbClientTests|FullyQualifiedName~HostedClientOptionsReachClientsAsync' --logger 'trx;LogFileName=amb-feedback-focused.trx' --results-directory 'C:\Users\mschier\.copilot\session-state\61d7ed73-c89e-406a-a049-1d8ba9681308\files\TestResults\feedback-focused' -- 'NUnit.WorkDirectory=D:\a61r'
```

**Whitespace and newly introduced style findings: passed.** `git diff
--check` and the following commands exited 0:

```powershell
& 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' format whitespace --folder --verify-no-changes --verbosity quiet --include 'src\Opc.Ua.AMB.Client\AmbClient.Bounds.cs' 'src\Opc.Ua.AMB.Client\AmbClient.Health.cs' 'src\Opc.Ua.AMB.Client\AmbClient.Structure.cs' 'src\Opc.Ua.AMB.Client\AmbClient.Values.cs' 'src\Opc.Ua.AMB.Client\AmbClientFactory.cs' 'src\Opc.Ua.AMB.Client\Hosting\OpcUaAmbClientBuilderExtensions.cs' 'tests\Opc.Ua.AMB.Tests\AmbBoundedClientTests.cs' 'tests\Opc.Ua.AMB.Tests\AmbBoundedClientTests.Traversal.cs' 'tests\Opc.Ua.AMB.Tests\AmbBoundedClientTests.Values.cs' 'tests\Opc.Ua.AMB.Tests\AmbClientServerE2eTests.cs' 'tests\Opc.Ua.AMB.Tests\AmbClientTests.cs'
$env:CustomTestTarget = 'net10.0'
$env:Configuration = 'Release'
& 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' format style 'tests\Opc.Ua.AMB.Tests\Opc.Ua.AMB.Tests.csproj' --no-restore --verify-no-changes --severity info --diagnostics IDE0039 --verbosity quiet --include 'tests\Opc.Ua.AMB.Tests\AmbClientServerE2eTests.cs' 'tests\Opc.Ua.AMB.Tests\AmbClientTests.cs'
```

The broader formatter check was **not clean**. It reported pre-existing
informational suggestions and design-time `NUnit1003` diagnostics for
generated browse-name constants, including pre-existing test cases.
Those diagnostics do not occur in the actual builds, which compile and
execute those cases successfully. Two new local-function suggestions
were corrected and rechecked above; no unrelated baseline cleanup or
suppression was included.

```powershell
$env:CustomTestTarget = 'net10.0'
& 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' format 'tests\Opc.Ua.AMB.Tests\Opc.Ua.AMB.Tests.csproj' --no-restore --verify-no-changes --severity info --verbosity quiet --include 'src\Opc.Ua.AMB.Client\AmbClient.Bounds.cs' 'src\Opc.Ua.AMB.Client\AmbClient.Health.cs' 'src\Opc.Ua.AMB.Client\AmbClient.Structure.cs' 'src\Opc.Ua.AMB.Client\AmbClient.Values.cs' 'src\Opc.Ua.AMB.Client\AmbClientFactory.cs' 'src\Opc.Ua.AMB.Client\Hosting\OpcUaAmbClientBuilderExtensions.cs' 'tests\Opc.Ua.AMB.Tests\AmbBoundedClientTests.cs' 'tests\Opc.Ua.AMB.Tests\AmbBoundedClientTests.Traversal.cs' 'tests\Opc.Ua.AMB.Tests\AmbBoundedClientTests.Values.cs' 'tests\Opc.Ua.AMB.Tests\AmbClientServerE2eTests.cs' 'tests\Opc.Ua.AMB.Tests\AmbClientTests.cs'
```

<details>
<summary>Initial extraction validation and environment history</summary>

The original extraction passed all 263 AMB tests on both TFMs. Its
initial net48 run had one unchanged maintenance-test server-start
timeout; shortening only NUnit.WorkDirectory resolved it, consistent
with certificate-store path lengths. This follow-up used a short
directory from the start and all 285 cases passed on both frameworks.

A no-restore test invocation during the follow-up initially executed no
tests; it was not counted as passing evidence. A no-restore build
identified `NETSDK1005` (missing net10.0 target assets after switching
TFMs). The normal test invocation restored the target assets. Initial
regression-test compilation issues were corrected before capturing the
16 failing behavioral cases.

Both samples were built during extraction on both TFMs; each command
below passed with 0 warnings and 0 errors. They were not rebuilt during
this review follow-up, which does not modify the samples.

```powershell
& 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' build 'samples\AMB\AmbClient\AmbClient.csproj' --configuration Release --framework net10.0 -p:CustomTestTarget=net10.0 --nologo --verbosity quiet
& 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' build 'samples\AMB\AmbServer\AmbServer.csproj' --configuration Release --framework net10.0 -p:CustomTestTarget=net10.0 --nologo --verbosity quiet
& 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' build 'samples\AMB\AmbClient\AmbClient.csproj' --configuration Release --framework net48 -p:CustomTestTarget=net48 --nologo --verbosity quiet
& 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' build 'samples\AMB\AmbServer\AmbServer.csproj' --configuration Release --framework net48 -p:CustomTestTarget=net48 --nologo --verbosity quiet
```

</details>

This remains a draft: the complete `UA.slnx` net48/net10.0 matrix,
coverage, CI/CodeQL and CLA status are not established by this focused
validation. No coverage increase or complete-solution pass is claimed.
The feedback commit is based on the original AMB extraction; integration
of later lower-layer updates is coordinated separately, without
rewriting this branch or native stack 4807.

## Related Issues

- References #4461, the existing tracking issue; this PR does not close
it.
- Extracted from #4462 at frozen head
`390c4a7a51b71da40caec2d22ef74518bbe670ce`; the source PR is not closed
by this PR.
- Immediate predecessor: #4802 (`marcschier-extract-client-and-server`),
above core/types #4801. The original extraction parent was
`86ff10d3994f132f7343a929abdae209bc2e69b7`.

This is an isolated extraction and review correction of the existing
tracked implementation, not a claim of new architectural sign-off.

## Checklist

_Put an `x` in the boxes that apply. You can complete these step by step
after opening the PR._

- [ ] 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.
- [ ] 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.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [x] I have addressed **all** PR feedback received.
2026-10-15 21:08:24 +02:00

265 lines
10 KiB
C#

/* ========================================================================
* 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.Globalization;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using AmbSample.Client;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.AMB;
using Opc.Ua.AMB.Client;
using Opc.Ua.Client;
using AmbClientOptions = AmbSample.Client.AmbClientOptions;
AmbClientOptions options = AmbClientOptions.Parse(args);
ITelemetryContext telemetry = DefaultTelemetry.Create(
logging => logging.AddConsole().SetMinimumLevel(LogLevel.Warning));
using var cancellation = new CancellationTokenSource();
Console.CancelKeyPress += (_, eventArgs) =>
{
eventArgs.Cancel = true;
cancellation.Cancel();
};
CancellationToken ct = cancellation.Token;
SampleSession connection = await SampleSession.ConnectAsync(options, telemetry, ct).ConfigureAwait(false);
await using ConfiguredAsyncDisposable _ = connection.ConfigureAwait(false);
// session.AssetManagement(...) also teaches the session the AMB structures,
// so root causes and maintenance details decode into their generated types.
AmbClient amb = connection.Session.AssetManagement(telemetry);
if (!amb.IsSupported)
{
Console.Error.WriteLine("The server does not publish the OPC 10000-110 AMB namespace.");
return 1;
}
// Every asset of the server, once, from the AMB alias categories.
ArrayOf<NodeId> assets = await amb.DiscoverAssetsAsync(ct).ConfigureAwait(false);
Console.WriteLine($"{assets.Count} manageable assets");
var snapshots = new List<AssetSnapshot>();
foreach (NodeId asset in assets.ToList())
{
AssetSnapshot snapshot = await amb.ReadAssetAsync(asset, ct).ConfigureAwait(false);
snapshots.Add(snapshot);
Print(snapshot);
}
Console.WriteLine();
foreach (AssetLocationKind kind in new[] { AssetLocationKind.Hierarchical, AssetLocationKind.Operational })
{
Console.WriteLine($"{kind} locations:");
foreach (AssetLocationNode location in (await amb.BrowseLocationsAsync(kind, ct).ConfigureAwait(false)).ToList())
{
Console.WriteLine($" {location.Path} ({location.Assets.Count} assets)");
}
}
// Add a link of our own to the first asset that has the AddIn, read it
// back and remove it again.
AssetSnapshot? withLinks = snapshots.Find(snapshot => snapshot.DocumentationLinks.Count > 0);
if (withLinks != null)
{
Console.WriteLine();
await TryAsync("AddLink / RemoveLink", async () =>
{
NodeId link = await amb.AddDocumentationLinkAsync(
withLinks.Asset,
"https://plant.example/wiring/line2",
new QualifiedName("WiringDiagram", withLinks.Asset.NamespaceIndex),
new LocalizedText("en", "Wiring diagram of line 2"),
cancellationToken: ct).ConfigureAwait(false);
Console.WriteLine($" added {link}");
foreach (DocumentationLinkRecord record in
(await amb.ReadDocumentationLinksAsync(withLinks.Asset, ct).ConfigureAwait(false)).ToList())
{
string editable = record.IsWritable ? " (editable)" : string.Empty;
Console.WriteLine($" {record.BrowseName.Name,-20} {UriOf(record)}{editable}");
}
await amb.RemoveDocumentationLinkAsync(withLinks.Asset, link, ct).ConfigureAwait(false);
Console.WriteLine(" removed it again");
}).ConfigureAwait(false);
}
// Write an AssetId; the server moves the asset in AssetsByAssetId.
AssetSnapshot? configurable = snapshots.Find(snapshot => snapshot.Identification.AssetId != null);
if (options.AssetId != null && configurable != null)
{
Console.WriteLine();
await TryAsync($"WriteAssetId {options.AssetId}", async () =>
{
await amb.WriteAssetIdAsync(configurable.Asset, options.AssetId, ct).ConfigureAwait(false);
ArrayOf<AssetAlias> found = await amb
.FindAssetsAsync(options.AssetId, AssetAliasCategory.ByAssetId, ct)
.ConfigureAwait(false);
Console.WriteLine($" found {found.Count} asset(s) by the new AssetId");
}).ConfigureAwait(false);
}
if (options.WatchSeconds > 0)
{
Console.WriteLine();
Console.WriteLine($"Streaming health alarms and maintenance for {options.WatchSeconds} s ...");
using var watch = CancellationTokenSource.CreateLinkedTokenSource(ct);
watch.CancelAfter(TimeSpan.FromSeconds(options.WatchSeconds));
Task alarms = WatchAlarmsAsync(amb, watch.Token);
Task maintenance = WatchMaintenanceAsync(amb, watch.Token);
await Task.WhenAll(alarms, maintenance).ConfigureAwait(false);
}
return 0;
static void Print(AssetSnapshot snapshot)
{
AssetIdentificationRecord id = snapshot.Identification;
Console.WriteLine();
Console.WriteLine($"{id.ProductInstanceUri} ({snapshot.Asset})");
Console.WriteLine(
$" {id.Manufacturer.Text} {id.Model.Text}, serial {id.SerialNumber}, AssetId {id.AssetId ?? "-"}");
if (id.HardwareRevision != null || id.SoftwareRevision != null)
{
Console.WriteLine(
$" hardware {id.HardwareRevision ?? "-"}, software {id.SoftwareRevision ?? "-"}, " +
$"revision counter {id.RevisionCounter}");
}
if (snapshot.DeviceHealth != null)
{
Console.WriteLine($" DeviceHealth {snapshot.DeviceHealth}");
}
foreach (AssetAlarmRecord alarm in snapshot.HealthAlarms.ToList())
{
string state = alarm.IsActive ? "active" : "inactive";
Console.WriteLine($" alarm {alarm.ConditionName}: {state}, {alarm.ConditionClass?.Name}");
}
foreach (MaintenanceActivityRecord activity in snapshot.MaintenanceActivities.ToList())
{
Console.WriteLine(
$" maintenance {activity.ConditionName}: {activity.State}, planned {Format(activity.PlannedDate)}");
}
foreach (DocumentationLinkRecord link in snapshot.DocumentationLinks.ToList())
{
Console.WriteLine($" link {link.BrowseName.Name}: {UriOf(link)}");
}
AssetContextRecord context = snapshot.Context;
if (context.HierarchicalLocation != null || context.OperationalLocation != null)
{
Console.WriteLine($" located at {context.HierarchicalLocation ?? context.OperationalLocation}");
}
if (context.LocalTime != null)
{
Console.WriteLine($" local time UTC{context.LocalTime.Offset / 60.0:+0.#;-0.#}");
}
foreach (ExpandedNodeId entry in context.Classifications.ToList())
{
Console.WriteLine($" classified as {entry}");
}
}
static async Task WatchAlarmsAsync(AmbClient amb, CancellationToken ct)
{
try
{
await foreach (AssetAlarmRecord alarm in amb
.ObserveHealthAlarmsAsync(cancellationToken: ct)
.ConfigureAwait(false))
{
string causes = string.Join(
", ",
alarm.PotentialRootCauses.ToList().ConvertAll(cause => cause.RootCause.Text));
string acknowledged = alarm.IsAcknowledged ? "acknowledged" : "unacknowledged";
string comment = alarm.Comment.IsNullOrEmpty ? string.Empty : $" - comment: {alarm.Comment.Text}";
Console.WriteLine(
$" {alarm.SourceName}/{alarm.ConditionName}: {(alarm.IsActive ? "ACTIVE" : "cleared")}, " +
$"{acknowledged}, severity {alarm.Severity} ({alarm.FaultCategory?.ToString() ?? "inactive"}) " +
alarm.Message.Text +
(causes.Length > 0 ? $" - root causes: {causes}" : string.Empty) +
comment);
// Acknowledge an alarm once it is cleared, so the server stops
// retaining it.
if (!alarm.IsActive && !alarm.IsAcknowledged && alarm.Retain)
{
await amb.AcknowledgeAsync(alarm.ConditionId, alarm.EventId, new LocalizedText("en", "seen"), ct)
.ConfigureAwait(false);
}
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// The watch ended.
}
}
static async Task WatchMaintenanceAsync(AmbClient amb, CancellationToken ct)
{
try
{
await foreach (MaintenanceActivityRecord activity in amb
.ObserveMaintenanceAsync(cancellationToken: ct)
.ConfigureAwait(false))
{
Console.WriteLine($" maintenance {activity.ConditionName}: {activity.State} - {activity.Message.Text}");
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// The watch ended.
}
}
static string UriOf(DocumentationLinkRecord link)
{
return string.IsNullOrEmpty(link.Uri) ? "(not set)" : link.Uri;
}
static string Format(DateTimeUtc value)
{
return value.IsNull
? "-"
: ((DateTime)value).ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
}
static async Task TryAsync(string name, Func<Task> action)
{
Console.WriteLine($"{name}:");
try
{
await action().ConfigureAwait(false);
}
catch (ServiceResultException ex)
{
Console.WriteLine($" refused: {ex.StatusCode}");
}
}