mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-18 14:43:08 +08:00
36194f6a02
## 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>
169 lines
7.2 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|