Files
UA-.NETStandard/tests/Opc.Ua.Server.Tests/ServerServiceLevelOwnershipTests.cs
romanett 36194f6a02 Avoid boxing and per-value copies on encoder, enum and server hot paths (#4715)
## Summary
Allocation audit of the per-message / per-value paths (binary codec,
built-in types, server request path, monitored items, client
notification dispatch) looking for boxing and avoidable per-value
copies. Measured with `GC.GetAllocatedBytesForCurrentThread` probe loops
on net10.0 **and net48**, and with `dotnet-trace` allocation traces of
the server load harness (#4707) for read/write/browse/subscription,
server and client side — a value type in the "allocated types" list is a
box.

Wire output and error behaviour are unchanged.

### Fixed
| Where | Cause | Frequency | Before → after |
|---|---|---|---|
| `Polyfills/System.IO.cs` (net48/netstandard2.0) |
`BinaryWriter.Write(ReadOnlySpan<byte>)` polyfill copied with
`ToArray()` — every string, array, ByteString, Guid write | per value |
32–64 B → 0; BrowseResponse×50 encode 15.3 → 10.9 KB |
| `EnumHelper` (net48/netstandard2.0) |
`EnumToInt32`/`Int32ToEnum`/`EnumToInt64` boxed outside
`NET8_0_OR_GREATER` (every enum field, `Variant.From<TEnum>`,
`EnumValue.From`) | per value | 24 B → 0 |
| `BinaryDecoder.ReadString` + stream raw reads (net48) | temp `byte[]`
per string, `BinaryReader.ReadBytes` | per value | BrowseResponse×50
decode 15.7 → 11.3 KB |
| `WriteGuid` / `ReadGuid` (all) | `ToByteArray()` / `byte[16]` | per
value (also Guid NodeIds) | 40 B → 0 each |
| `VariantHelper.TryCastTo<TEnum>` (`DataValue.GetValue<TEnum>`),
`EventRecordFieldReaders.GetEnum` | `Enum.ToObject` box | per call / per
event field | 24 B → 0 |
| `NodeManagerServiceDispatcher.GetManagerHandleAsync` | interface
enumerator per validated node | per node | Write-100: 151.8 → 148.5
KB/request |

### Cold paths (third commit)
- `Server.Auditing` write handler: `Convert.ToBoolean(object)` got a
boxed `Variant` (not `IConvertible`) and threw `InvalidCastException` on
any in-process write; now takes the boolean, `BadTypeMismatch` otherwise
(new test).
- `UpdateServerServiceLevel`: `Convert.ToByte(byte, IFormatProvider)`
bound to the object overload.
- Client publish-state / recovery-policy `HasFlag` (boxes on net48) →
`(x & F) == F`.
- `SequenceReader` big-endian float on net48/netstandard2.0 used
`Convert.ToSingle(int)` (numeric conversion) instead of reinterpreting
the bits.
- JSON enum strings: one `Enum.GetName` + `string.Concat` instead of
`IsDefined` + boxing `CoreUtils.Format`; same output (new test for the
`EnumValue` overload).

Left as is: `SamplingGroup` start-up `Convert.ToInt32(double, provider)`
(rewritten by #4707), `SecureChannelRenewed` formatting (transport files
in flight on the client perf branch), `Utils.IsEqual<T>` object path for
structs (cold callers; changing it would mix `CompareTo` and `Equals`
semantics), Machinery `HasFlag` (startup config).

### Not changed here
- `BinaryEncoder.GetNodeIdEncoding` `Convert.ToUInt32(object)` /
`Convert.ToByte(enum)` (72 B per NodeId encode, largest single item) and
`ChannelAsyncOperation.Complete` boxing the `int` result are handled on
the client performance branch.
- By design: `Variant` / `NodeId` store `ArrayOf<T>`, Guid, ByteString
identifiers boxed (`Variant.Copy` re-boxes RolePermissions during
permission checks).

## Tests
- New `BinaryCodecAllocationTests`: round trips in buffer and stream
decoder mode, truncation errors, allocation per call (< 1 B/call over
20k calls; the counter can be off by an allocation quantum when other
tests GC).
- `Enum.ToObject` equivalence for `TryCastTo` and
`EventRecordFieldReaders.GetEnum` across all underlying types.
- net10.0 (third commit): Types 11465, affected Server 87, Client
subscription 642 passed; net48 Types 11458 passed, `UA.slnx` 0 warnings.
- net10.0: Types 11464, Core.Encoders 2947, Server 8386 passed; Core
6197 passed, 1 unrelated
(`ActivityTraceFlags_Metrics_ShouldRecordMetricsOnly`, passes alone).
- net48: Types 11457, Core.Encoders 2947, EventRecordFieldReaders 25
passed; `UA.slnx` builds with 0 warnings.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:35:57 +02:00

169 lines
7.2 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.Threading.Tasks;
using NUnit.Framework;
using Opc.Ua.Redundancy.Server;
using Opc.Ua.Server.TestFramework;
namespace Opc.Ua.Server.Tests
{
[TestFixture]
[Category("ServiceLevel")]
public sealed class ServerServiceLevelOwnershipTests
{
[SetUp]
public async Task StartServerAsync()
{
m_fixture = new ServerFixture<StandardServer>(telemetry => new StandardServer(telemetry))
{
SecurityNone = true
};
await m_fixture.LoadConfigurationAsync().ConfigureAwait(false);
m_fixture.Config.ServerConfiguration!.MaxSessionCount = 1;
m_server = await m_fixture.StartAsync().ConfigureAwait(false);
EndpointDescription endpoint = m_server.GetEndpoints().Find(
description => description.SecurityPolicyUri == SecurityPolicies.None);
m_channel = new SecureChannelContext("service-level", endpoint, RequestEncoding.Binary);
}
[TearDown]
public async Task StopServerAsync()
{
await m_fixture.StopAsync().ConfigureAwait(false);
}
[TestCase((byte)210)]
[TestCase((byte)255)]
public async Task ProviderOwnedHealthyLevelSurvivesSessionChurnAsync(byte initialLevel)
{
var provider = new MutableProvider(initialLevel);
var startup = new ServiceLevelStartupTask(provider);
await startup.OnServerStartedAsync(m_server.CurrentInstance).ConfigureAwait(false);
Assert.That(m_server.CurrentInstance.ServerObject.ServiceLevel!.Value, Is.EqualTo(initialLevel));
CreateSessionResponse created = await CreateSessionAsync().ConfigureAwait(false);
Assert.That(m_server.CurrentInstance.ServerObject.ServiceLevel.Value, Is.EqualTo(initialLevel));
provider.SetLevel(235);
Assert.That(m_server.CurrentInstance.ServerObject.ServiceLevel.Value, Is.EqualTo((byte)235));
await CloseSessionAsync(created).ConfigureAwait(false);
Assert.That(m_server.CurrentInstance.ServerObject.ServiceLevel.Value, Is.EqualTo((byte)235));
}
[Test]
public async Task ASecondProviderCannotTakeServiceLevelOwnershipAsync()
{
var first = new MutableProvider(210);
var second = new MutableProvider(240);
await new ServiceLevelStartupTask(first).OnServerStartedAsync(m_server.CurrentInstance)
.ConfigureAwait(false);
Assert.ThrowsAsync<InvalidOperationException>(async () =>
await new ServiceLevelStartupTask(second).OnServerStartedAsync(m_server.CurrentInstance)
.ConfigureAwait(false));
second.SetLevel(255);
Assert.That(m_server.CurrentInstance.ServerObject.ServiceLevel!.Value, Is.EqualTo((byte)210));
first.SetLevel(215);
Assert.That(m_server.CurrentInstance.ServerObject.ServiceLevel.Value, Is.EqualTo((byte)215));
}
[Test]
public async Task UnownedServiceLevelStillTracksSessionHeadroomAsync()
{
Assert.That(m_server.CurrentInstance.ServerObject.ServiceLevel!.Value, Is.EqualTo((byte)255));
CreateSessionResponse created = await CreateSessionAsync().ConfigureAwait(false);
Assert.That(m_server.CurrentInstance.ServerObject.ServiceLevel.Value, Is.EqualTo((byte)200));
await CloseSessionAsync(created).ConfigureAwait(false);
Assert.That(m_server.CurrentInstance.ServerObject.ServiceLevel.Value, Is.EqualTo((byte)255));
}
/// <summary>
/// The Server.Auditing write handler converted the Variant with
/// Convert.ToBoolean(object), which throws for a Variant.
/// </summary>
[Test]
public void AuditingWriteHandlerTakesTheBooleanValue()
{
IServerInternal server = m_server.CurrentInstance;
PropertyState<bool> auditing = server.ServerObject.Auditing!;
bool initial = server.Auditing;
Variant value = !initial;
ServiceResult result = auditing.OnSimpleWriteValue!(
server.DefaultSystemContext, auditing, ref value);
Assert.That(ServiceResult.IsGood(result), Is.True);
Assert.That(server.Auditing, Is.EqualTo(!initial));
Variant mismatch = "true";
result = auditing.OnSimpleWriteValue!(
server.DefaultSystemContext, auditing, ref mismatch);
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadTypeMismatch));
Assert.That(server.Auditing, Is.EqualTo(!initial));
}
private ValueTask<CreateSessionResponse> CreateSessionAsync()
{
return m_server.CreateSessionAsync(
m_channel, new RequestHeader(), null, null, null, "service-level",
default, default, 60000, 0, RequestLifetime.None);
}
private ValueTask<CloseSessionResponse> CloseSessionAsync(CreateSessionResponse session)
{
return m_server.CloseSessionAsync(
m_channel, new RequestHeader { AuthenticationToken = session.AuthenticationToken },
true, RequestLifetime.None);
}
private ServerFixture<StandardServer> m_fixture;
private StandardServer m_server;
private SecureChannelContext m_channel;
private sealed class MutableProvider(byte level) : IServiceLevelProvider
{
public event Action<byte>? ServiceLevelChanged;
public byte GetServiceLevel()
{
return m_level;
}
public void SetLevel(byte value)
{
m_level = value;
ServiceLevelChanged?.Invoke(value);
}
private byte m_level = level;
}
}
}