mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-20 16:52:07 +08:00
9b034f8291
# Description Nullable reference types are now enabled once, in `common.props`, and every project in the repository builds warning-clean with them. **Configuration** - `<Nullable>enable</Nullable>` lives in `common.props`. All per-project `<Nullable>` settings are removed: 178 project files (enable, annotations, and the `disable` in the three Aot test projects). - 419 per-file `#nullable enable`/`restore` directives and the 4 `#nullable disable` directives are removed. - Kept on purpose: - The 5 `<auto-generated>` schema files (`UANodeSet.cs`, `UA Type Dictionary.cs`, `OPCBinarySchema.cs`, `SecuredApplication.cs`, `PInvokeHelper.cs`) keep `#nullable enable`. The compiler ignores the project setting in generated code, so the directive is required there. - `tests/Opc.Ua.Interop.LegacyPeer` keeps `disable`. It builds against the 1.5.378 packages and does not import the repository props. - `docs/DeveloperGuide.md` and `.github/copilot-instructions.md` now describe the central setting. **Migration** (the 43 unset and 13 annotations-only projects, roughly 10k diagnostics) - **Production code: annotated at the declarations.** Covers `tools/Opc.Ua.SourceGeneration.Core`, `src/Opc.Ua.Lds.Server` and the LDS sample. - Members, parameters and returns are nullable where null actually flows. - Null-check helpers carry `[NotNullWhen]` (`IsNull`, `IsValidXmlName`, `IsMethodTypeDesign`). - `CoreUtils.Format`, `Utils.Format`, `ITemplateWriter.WriteLine` take `params object?[]`. `Template.AddReplacement` takes `string?` (rendering already skips null replacements). - `IRegisteredServerStore.RegisterAsync` returns `Task<RegistrationEntry?>`: it returns null when a registration is removed. - Dereferences backed by an invariant (for example, the validated model) use `!`, so runtime behaviour is unchanged. - **Behavioural changes in production code** (small, deliberate): - `NodeSetToModelDesign` throws `InvalidOperationException` when the file is not a NodeSet. Before, it threw a NullReferenceException on the next line. - `ModelDesignValidator` starts its lookup tables empty instead of null. A redundant `if (m_nodes == null) return;` guard is gone. - **Test code: minimal annotations.** - `?` on values that can be null, `!` where the test asserts non-null. - `= null!` for fixture members assigned in SetUp. - `TryGetStructure<T>(out T? x)` where the inferred `T?` broke the `IEncodeable` constraint. - A few redundant `== null` checks on structs (`NodeId`, `ArrayOf<T>`) folded into the adjacent `IsNull`/`Count` checks. **Notes for reviewers** - The diff is large (~1,500 files) but almost entirely mechanical: `?`, `!` and directive removal. The non-annotation changes are listed above. - Known follow-up, not done here: `IModelDesign.Namespaces/TargetNamespace/NamespaceUris` stay non-nullable even though the validator returns null before it loads a dictionary. Making them nullable would ripple through every generator call site. ## Related Issues - Fixes #4439 ## 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. - [ ] 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. - [ ] I have addressed **all** PR feedback received. The "no new warnings" box covers net10.0 and net48: full non-incremental builds of `UA.slnx`, `tools/SourceGeneration.slnx`, `tools/MigrationAnalyzer.slnx` and the Aot test projects finished with 0 warnings and 0 errors under the repository's warnings-as-errors settings. net9.0 and net8.0 also had no nullable diagnostics, but they were not rebuilt after the final fixes. Tests have not been run locally yet. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
946 lines
37 KiB
C#
946 lines
37 KiB
C#
/* ========================================================================
|
|
* 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.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.Diagnostics.Tracing;
|
|
using System.Globalization;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.Logging;
|
|
using NUnit.Framework;
|
|
using Opc.Ua.Client.TestFramework;
|
|
using Opc.Ua.Server.TestFramework;
|
|
using Opc.Ua.Tests;
|
|
using Quickstarts.ReferenceServer;
|
|
using ISession = Opc.Ua.Client.ISession;
|
|
using MonitoredItem = Opc.Ua.Client.MonitoredItem;
|
|
using Subscription = Opc.Ua.Client.Subscription;
|
|
|
|
#if NET10_0_OR_GREATER
|
|
namespace Opc.Ua.Sessions.Tests
|
|
{
|
|
/// <summary>
|
|
/// Explicit subscription-driven ArrayPool diagnostics for issue #3988.
|
|
/// </summary>
|
|
[TestFixture]
|
|
[Explicit]
|
|
[NonParallelizable]
|
|
[Category("LoadTest")]
|
|
[Category("ArrayPool")]
|
|
[SetCulture("en-us")]
|
|
[SetUICulture("en-us")]
|
|
public sealed class SubscriptionArrayPoolDiagnosticsLoadTests : ClientTestFramework
|
|
{
|
|
private const int kParallelPublishThreshold = 256;
|
|
private const int kPublishingIntervalMs = 50;
|
|
private const int kWriterIntervalMs = 10;
|
|
private const int kScenarioDurationMs = 6000;
|
|
private const int kTrackedNodeCount = 1;
|
|
private const int kTcpMinBufferSize = 8192;
|
|
private const int kBufferCookieLength = 1;
|
|
private const int kOutstandingToleranceCount = 8;
|
|
private const int kPeakOutstandingMultiplier = 8;
|
|
|
|
private int m_transportMaxBufferSize = 65535;
|
|
|
|
public SubscriptionArrayPoolDiagnosticsLoadTests()
|
|
: base(Utils.UriSchemeOpcTcp, NUnitTelemetryContext.Create(logLevel: LogLevel.Warning))
|
|
{
|
|
SingleSession = false;
|
|
UseSamplingGroupsInReferenceNodeManager = false;
|
|
}
|
|
|
|
[Test]
|
|
[TestCase(65535, TestName = "SubscriptionArrayPoolDiagnostics_MaxBufferSize65535")]
|
|
[TestCase(65536, TestName = "SubscriptionArrayPoolDiagnostics_MaxBufferSize65536")]
|
|
public async Task SubscriptionPublishesKeepArrayPoolBalancedAsync(int maxBufferSize)
|
|
{
|
|
ConfigureFixture(maxBufferSize);
|
|
|
|
try
|
|
{
|
|
await OneTimeSetUpAsync().ConfigureAwait(false);
|
|
|
|
ArrayPoolDiagnosticScenario[] scenarios =
|
|
[
|
|
new(
|
|
"serial-threshold-minus-one",
|
|
kParallelPublishThreshold - 1,
|
|
kScenarioDurationMs,
|
|
kWriterIntervalMs,
|
|
kPublishingIntervalMs,
|
|
SlowConsumerDelayMs: 0,
|
|
SlowConsumerEveryNthSession: 0),
|
|
new(
|
|
"parallel-threshold-plus-one",
|
|
kParallelPublishThreshold + 1,
|
|
kScenarioDurationMs,
|
|
kWriterIntervalMs,
|
|
kPublishingIntervalMs,
|
|
SlowConsumerDelayMs: 0,
|
|
SlowConsumerEveryNthSession: 0),
|
|
new(
|
|
"parallel-threshold-plus-one-slow-consumer",
|
|
kParallelPublishThreshold + 1,
|
|
kScenarioDurationMs,
|
|
kWriterIntervalMs,
|
|
kPublishingIntervalMs,
|
|
SlowConsumerDelayMs: 5,
|
|
SlowConsumerEveryNthSession: 8)
|
|
];
|
|
|
|
foreach (ArrayPoolDiagnosticScenario scenario in scenarios)
|
|
{
|
|
await RunScenarioAsync(scenario).ConfigureAwait(false);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
await OneTimeTearDownAsync().ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
public override async Task OneTimeSetUpAsync()
|
|
{
|
|
await OneTimeSetUpCoreAsync(securityNone: true).ConfigureAwait(false);
|
|
ClientFixture.Config.TransportQuotas!.MaxBufferSize = m_transportMaxBufferSize;
|
|
ClientFixture.SessionTimeout = 300_000;
|
|
ClientFixture.OperationTimeout = 90_000;
|
|
}
|
|
|
|
public override async Task CreateReferenceServerFixtureAsync(
|
|
bool enableTracing,
|
|
bool disableActivityLogging,
|
|
bool securityNone)
|
|
{
|
|
ServerFixture = new ServerFixture<ReferenceServer>(
|
|
t => new ReferenceServer(t),
|
|
enableTracing,
|
|
disableActivityLogging)
|
|
{
|
|
UriScheme = UriScheme,
|
|
SecurityNone = securityNone,
|
|
AutoAccept = true,
|
|
AllNodeManagers = AllNodeManagers,
|
|
OperationLimits = true,
|
|
UseSamplingGroupsInReferenceNodeManager = UseSamplingGroupsInReferenceNodeManager,
|
|
TransportBindingRegistry = TransportBindingRegistry
|
|
};
|
|
|
|
await ServerFixture.LoadConfigurationAsync(PkiRoot).ConfigureAwait(false);
|
|
ServerFixture.Config.TransportQuotas!.MaxBufferSize = m_transportMaxBufferSize;
|
|
ServerFixture.Config.TransportQuotas.MaxMessageSize = TransportQuotaMaxMessageSize;
|
|
ServerFixture.Config.TransportQuotas.MaxByteStringLength = ServerFixture
|
|
.Config
|
|
.TransportQuotas
|
|
.MaxStringLength = TransportQuotaMaxStringLength;
|
|
ServerFixture.Config.TransportQuotas.SecurityTokenLifetime = SecurityTokenLifetime;
|
|
|
|
void AddExplicitUserTokenPolicies(string securityPolicyUri)
|
|
{
|
|
if (SecurityPolicies.Default.GetInfo(securityPolicyUri) == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
ServerFixture.Config.ServerConfiguration.UserTokenPolicies +=
|
|
new UserTokenPolicy(UserTokenType.UserName)
|
|
{
|
|
SecurityPolicyUri = securityPolicyUri
|
|
};
|
|
|
|
ServerFixture.Config.ServerConfiguration.UserTokenPolicies +=
|
|
new UserTokenPolicy(UserTokenType.Certificate)
|
|
{
|
|
SecurityPolicyUri = securityPolicyUri
|
|
};
|
|
|
|
ServerFixture.Config.ServerConfiguration.UserTokenPolicies +=
|
|
new UserTokenPolicy(UserTokenType.IssuedToken)
|
|
{
|
|
IssuedTokenType = Profiles.JwtUserToken,
|
|
PolicyId = Profiles.JwtUserToken,
|
|
SecurityPolicyUri = securityPolicyUri
|
|
};
|
|
}
|
|
|
|
ServerFixture.Config.ServerConfiguration!.UserTokenPolicies +=
|
|
new UserTokenPolicy(UserTokenType.UserName);
|
|
ServerFixture.Config.ServerConfiguration.UserTokenPolicies +=
|
|
new UserTokenPolicy(UserTokenType.Certificate);
|
|
ServerFixture.Config.ServerConfiguration.UserTokenPolicies +=
|
|
new UserTokenPolicy(UserTokenType.IssuedToken)
|
|
{
|
|
IssuedTokenType = Profiles.JwtUserToken
|
|
};
|
|
|
|
AddExplicitUserTokenPolicies(SecurityPolicies.Basic128Rsa15);
|
|
|
|
foreach (string securityPolicyUri in GetSupportedEccPolicyUris())
|
|
{
|
|
AddExplicitUserTokenPolicies(securityPolicyUri);
|
|
}
|
|
|
|
ServerFixture.Config.ServerConfiguration.MaxChannelCount = MaxChannelCount;
|
|
ServerFixture.Config.ServerConfiguration.MaxSessionCount = MaxSessionCount;
|
|
ServerFixture.Config.ServerConfiguration.MaxFailedAuthenticationAttempts =
|
|
MaxFailedAuthenticationAttempts;
|
|
ServerFixture.Config.ServerConfiguration.MaxRequestThreadCount =
|
|
MaxRequestThreadCount;
|
|
ServerFixture.Config.ServerConfiguration.MinRequestThreadCount =
|
|
MinRequestThreadCount;
|
|
ServerFixture.Config.ServerConfiguration.MaxSubscriptionCount =
|
|
MaxSubscriptionCount;
|
|
ServerFixture.Config.ServerConfiguration.MaxQueuedRequestCount = 100000;
|
|
ReferenceServer = await ServerFixture.StartAsync().ConfigureAwait(false);
|
|
ReferenceServer.TokenValidator = TokenValidator;
|
|
ServerFixturePort = ServerFixture.Port;
|
|
}
|
|
|
|
private void ConfigureFixture(int maxBufferSize)
|
|
{
|
|
m_transportMaxBufferSize = maxBufferSize;
|
|
MaxChannelCount = 323;
|
|
MaxSessionCount = 320;
|
|
MaxSubscriptionCount = 320;
|
|
MaxFailedAuthenticationAttempts = 0;
|
|
MaxRequestThreadCount = 200;
|
|
MinRequestThreadCount = 50;
|
|
}
|
|
|
|
private async Task RunScenarioAsync(ArrayPoolDiagnosticScenario scenario)
|
|
{
|
|
var readers = new ConcurrentBag<ReaderSessionBundle>();
|
|
var writerErrors = new ConcurrentQueue<string>();
|
|
long notificationCount = 0;
|
|
long callbackCount = 0;
|
|
int writeCount = 0;
|
|
bool readersClosed = false;
|
|
CancellationTokenSource? writerCts = null;
|
|
Task? writerTask = null;
|
|
ISession? writerSession = null;
|
|
|
|
try
|
|
{
|
|
writerSession = await ClientFixture
|
|
.ConnectAsync(ServerUrl, SecurityPolicies.None)
|
|
.ConfigureAwait(false);
|
|
|
|
IDictionary<NodeId, Type> nodes = GetTestSetStaticMassNumeric(
|
|
writerSession.NamespaceUris);
|
|
KeyValuePair<NodeId, Type>[] monitoredNodes = [.. nodes
|
|
.Where(static value => value.Value == typeof(uint))
|
|
.Take(kTrackedNodeCount)];
|
|
|
|
if (monitoredNodes.Length < kTrackedNodeCount)
|
|
{
|
|
Assert.Ignore("No writable UInt32 nodes were found for the subscription load harness.");
|
|
}
|
|
|
|
await CreateReaderSessionsAsync(
|
|
scenario,
|
|
monitoredNodes,
|
|
readers,
|
|
() => Interlocked.Increment(ref callbackCount),
|
|
count => Interlocked.Add(ref notificationCount, count))
|
|
.ConfigureAwait(false);
|
|
|
|
await Task.Delay(TimeSpan.FromMilliseconds(500)).ConfigureAwait(false);
|
|
|
|
int trackedMinimumBufferSize =
|
|
GetTrackedMinimumBufferSize(m_transportMaxBufferSize);
|
|
|
|
using var listener = new ArrayPoolEventListener(trackedMinimumBufferSize);
|
|
|
|
long allocatedBytesBefore = GetTotalAllocatedBytes();
|
|
int gen2Before = GC.CollectionCount(2);
|
|
writerCts = new CancellationTokenSource();
|
|
|
|
// CA2025 false positive: writerTask is awaited in the timed run and
|
|
// again in finally before writerSession is closed/disposed.
|
|
// TODO: Refactor the harness shape once CA2025 recognizes this pattern.
|
|
#pragma warning disable CA2025
|
|
writerTask = RunWriterAsync(
|
|
writerSession,
|
|
monitoredNodes,
|
|
scenario,
|
|
writerErrors,
|
|
() => Interlocked.Increment(ref writeCount),
|
|
writerCts.Token);
|
|
#pragma warning restore CA2025
|
|
|
|
await Task.Delay(
|
|
TimeSpan.FromMilliseconds(scenario.DurationMs))
|
|
.ConfigureAwait(false);
|
|
|
|
writerCts.Cancel();
|
|
|
|
try
|
|
{
|
|
await writerTask.ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Expected when the timed run ends.
|
|
}
|
|
|
|
await Task.Delay(
|
|
TimeSpan.FromMilliseconds(
|
|
(scenario.PublishingIntervalMs * 4) +
|
|
(scenario.SlowConsumerDelayMs * 8)))
|
|
.ConfigureAwait(false);
|
|
|
|
await CloseSessionsAsync(readers.Select(static value => value.Session))
|
|
.ConfigureAwait(false);
|
|
readersClosed = true;
|
|
|
|
await CloseSessionAsync(writerSession).ConfigureAwait(false);
|
|
writerSession = null;
|
|
|
|
bool quiesced = await WaitForQuiescenceAsync(
|
|
listener,
|
|
TimeSpan.FromSeconds(2),
|
|
TimeSpan.FromSeconds(20))
|
|
.ConfigureAwait(false);
|
|
|
|
ArrayPoolMetrics metrics = listener.CreateMetrics();
|
|
long allocatedBytesAfter = GetTotalAllocatedBytes();
|
|
int gen2After = GC.CollectionCount(2);
|
|
|
|
WriteScenarioResults(
|
|
scenario,
|
|
writeCount,
|
|
callbackCount,
|
|
notificationCount,
|
|
allocatedBytesAfter - allocatedBytesBefore,
|
|
gen2After - gen2Before,
|
|
quiesced,
|
|
writerErrors,
|
|
metrics);
|
|
|
|
Assert.That(writeCount, Is.GreaterThan(0), "The writer produced no writes.");
|
|
Assert.That(callbackCount, Is.GreaterThan(0), "No data change callbacks were observed.");
|
|
Assert.That(notificationCount, Is.GreaterThan(0), "No monitored item notifications were observed.");
|
|
Assert.That(quiesced, Is.True, "ArrayPool activity did not quiesce after the scenario.");
|
|
Assert.That(metrics.RentedCount, Is.GreaterThan(0), "No tracked ArrayPool rents were observed.");
|
|
Assert.That(metrics.ReturnedCount, Is.GreaterThan(0), "No tracked ArrayPool returns were observed.");
|
|
Assert.That(
|
|
Math.Abs(metrics.OutstandingCount),
|
|
Is.LessThanOrEqualTo(kOutstandingToleranceCount),
|
|
"Tracked ArrayPool rent/return balance did not settle after quiescence.");
|
|
Assert.That(
|
|
Math.Abs(metrics.OutstandingBytes),
|
|
Is.LessThanOrEqualTo(
|
|
(long)kOutstandingToleranceCount * metrics.MaxObservedBufferSize),
|
|
"Tracked ArrayPool outstanding bytes did not settle after quiescence.");
|
|
Assert.That(
|
|
metrics.PeakOutstandingCount,
|
|
Is.LessThanOrEqualTo(
|
|
scenario.PublishQueueCount * kPeakOutstandingMultiplier),
|
|
"Tracked ArrayPool outstanding buffers grew beyond the session-scaled envelope.");
|
|
}
|
|
finally
|
|
{
|
|
writerCts?.Cancel();
|
|
|
|
if (writerTask != null)
|
|
{
|
|
try
|
|
{
|
|
await writerTask.ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Expected during cleanup.
|
|
}
|
|
}
|
|
|
|
writerCts?.Dispose();
|
|
|
|
if (!readersClosed)
|
|
{
|
|
await CloseSessionsAsync(readers.Select(static value => value.Session))
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
if (writerSession != null)
|
|
{
|
|
await CloseSessionAsync(writerSession).ConfigureAwait(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task CreateReaderSessionsAsync(
|
|
ArrayPoolDiagnosticScenario scenario,
|
|
KeyValuePair<NodeId, Type>[] monitoredNodes,
|
|
ConcurrentBag<ReaderSessionBundle> readers,
|
|
Action onCallback,
|
|
Action<int> onNotification)
|
|
{
|
|
const int maxConcurrentConnects = 24;
|
|
using var gate = new SemaphoreSlim(maxConcurrentConnects, maxConcurrentConnects);
|
|
var tasks = new List<Task>(scenario.PublishQueueCount);
|
|
|
|
for (int sessionIndex = 0; sessionIndex < scenario.PublishQueueCount; sessionIndex++)
|
|
{
|
|
int localSessionIndex = sessionIndex;
|
|
tasks.Add(Task.Run(async () =>
|
|
{
|
|
await gate.WaitAsync().ConfigureAwait(false);
|
|
try
|
|
{
|
|
ISession session = await ClientFixture
|
|
.ConnectAsync(ServerUrl, SecurityPolicies.None)
|
|
.ConfigureAwait(false);
|
|
|
|
session.MinPublishRequestCount = 1;
|
|
session.MaxPublishRequestCount = 1;
|
|
|
|
var subscription = new Subscription(session.DefaultSubscription)
|
|
{
|
|
PublishingEnabled = true,
|
|
PublishingInterval = scenario.PublishingIntervalMs,
|
|
SequentialPublishing = true,
|
|
DisableMonitoredItemCache = true
|
|
};
|
|
|
|
foreach (KeyValuePair<NodeId, Type> monitoredNode in monitoredNodes)
|
|
{
|
|
var item = new MonitoredItem(subscription.DefaultItem)
|
|
{
|
|
StartNodeId = monitoredNode.Key,
|
|
AttributeId = Attributes.Value,
|
|
MonitoringMode = MonitoringMode.Reporting,
|
|
SamplingInterval = 0,
|
|
QueueSize = 1
|
|
};
|
|
|
|
subscription.AddItem(item);
|
|
}
|
|
|
|
bool isSlowConsumer = scenario.SlowConsumerDelayMs > 0 &&
|
|
scenario.SlowConsumerEveryNthSession > 0 &&
|
|
localSessionIndex % scenario.SlowConsumerEveryNthSession == 0;
|
|
|
|
subscription.FastDataChangeCallback = (_, notification, _) =>
|
|
{
|
|
onCallback();
|
|
onNotification(notification.MonitoredItems.Count);
|
|
|
|
if (isSlowConsumer)
|
|
{
|
|
Thread.Sleep(scenario.SlowConsumerDelayMs);
|
|
}
|
|
};
|
|
|
|
session.AddSubscription(subscription);
|
|
await subscription.CreateAsync().ConfigureAwait(false);
|
|
|
|
readers.Add(new ReaderSessionBundle(session, subscription));
|
|
}
|
|
finally
|
|
{
|
|
gate.Release();
|
|
}
|
|
}));
|
|
}
|
|
|
|
await Task.WhenAll(tasks).ConfigureAwait(false);
|
|
}
|
|
|
|
private async Task RunWriterAsync(
|
|
ISession writerSession,
|
|
KeyValuePair<NodeId, Type>[] monitoredNodes,
|
|
ArrayPoolDiagnosticScenario scenario,
|
|
ConcurrentQueue<string> writerErrors,
|
|
Action onWrite,
|
|
CancellationToken ct)
|
|
{
|
|
WriteValue[] nodesToWrite = [.. monitoredNodes
|
|
.Select(static value => new WriteValue
|
|
{
|
|
NodeId = value.Key,
|
|
AttributeId = Attributes.Value,
|
|
Value = new DataValue(new Variant((uint)0))
|
|
})];
|
|
|
|
uint nextValue = 0;
|
|
|
|
while (!ct.IsCancellationRequested)
|
|
{
|
|
nextValue++;
|
|
|
|
for (int i = 0; i < nodesToWrite.Length; i++)
|
|
{
|
|
nodesToWrite[i].Value = new DataValue(new Variant(nextValue));
|
|
}
|
|
|
|
try
|
|
{
|
|
await writerSession.WriteAsync(null, nodesToWrite, ct).ConfigureAwait(false);
|
|
onWrite();
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
throw;
|
|
}
|
|
catch (ServiceResultException sre)
|
|
when (sre.StatusCode == StatusCodes.BadRequestInterrupted ||
|
|
sre.StatusCode == StatusCodes.BadSecureChannelClosed ||
|
|
sre.StatusCode == StatusCodes.BadSessionIdInvalid)
|
|
{
|
|
writerErrors.Enqueue(sre.StatusCode.ToString());
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
writerErrors.Enqueue(ex.GetType().Name + ":" + ex.Message);
|
|
}
|
|
|
|
await Task.Delay(
|
|
TimeSpan.FromMilliseconds(scenario.WriterIntervalMs),
|
|
ct)
|
|
.ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
private static async Task CloseSessionsAsync(IEnumerable<ISession> sessions)
|
|
{
|
|
ISession[] sessionArray = [.. sessions
|
|
.Where(static value => value != null)
|
|
.Distinct()];
|
|
|
|
if (sessionArray.Length == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
await Task.WhenAll(sessionArray.Select(CloseSessionAsync)).ConfigureAwait(false);
|
|
}
|
|
|
|
private static async Task CloseSessionAsync(ISession session)
|
|
{
|
|
try
|
|
{
|
|
await session.CloseAsync().ConfigureAwait(false);
|
|
}
|
|
catch
|
|
{
|
|
// Best-effort cleanup for an explicit diagnostics harness.
|
|
}
|
|
finally
|
|
{
|
|
session.Dispose();
|
|
}
|
|
}
|
|
|
|
private static async Task<bool> WaitForQuiescenceAsync(
|
|
ArrayPoolEventListener listener,
|
|
TimeSpan stablePeriod,
|
|
TimeSpan timeout)
|
|
{
|
|
long deadline = Stopwatch.GetTimestamp() +
|
|
(long)(timeout.TotalSeconds * Stopwatch.Frequency);
|
|
|
|
while (Stopwatch.GetTimestamp() < deadline)
|
|
{
|
|
if (listener.GetIdleDuration() >= stablePeriod)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
await Task.Delay(250).ConfigureAwait(false);
|
|
}
|
|
|
|
return listener.GetIdleDuration() >= stablePeriod;
|
|
}
|
|
|
|
private void WriteScenarioResults(
|
|
ArrayPoolDiagnosticScenario scenario,
|
|
int writeCount,
|
|
long callbackCount,
|
|
long notificationCount,
|
|
long allocatedBytesDelta,
|
|
int gen2CollectionsDelta,
|
|
bool quiesced,
|
|
ConcurrentQueue<string> writerErrors,
|
|
ArrayPoolMetrics metrics)
|
|
{
|
|
TestContext.Out.WriteLine(
|
|
$"ARRAYPOOL_DIAGNOSTIC scenario={scenario.Name}; " +
|
|
$"maxBufferSize={m_transportMaxBufferSize}; " +
|
|
$"publishQueues={scenario.PublishQueueCount}; " +
|
|
$"slowConsumerDelayMs={scenario.SlowConsumerDelayMs}; " +
|
|
$"slowConsumerEveryNthSession={scenario.SlowConsumerEveryNthSession}; " +
|
|
$"writes={writeCount}; " +
|
|
$"callbacks={callbackCount}; " +
|
|
$"notifications={notificationCount}; " +
|
|
$"allocatedBytesDelta={allocatedBytesDelta}; " +
|
|
$"gen2CollectionsDelta={gen2CollectionsDelta}; " +
|
|
$"quiesced={quiesced}; " +
|
|
$"rented={metrics.RentedCount}; " +
|
|
$"returned={metrics.ReturnedCount}; " +
|
|
$"allocated={metrics.AllocatedCount}; " +
|
|
$"dropped={metrics.DroppedCount}; " +
|
|
$"trimmed={metrics.TrimmedCount}; " +
|
|
$"outstanding={metrics.OutstandingCount}; " +
|
|
$"outstandingBytes={metrics.OutstandingBytes}; " +
|
|
$"peakOutstanding={metrics.PeakOutstandingCount}; " +
|
|
$"peakOutstandingBytes={metrics.PeakOutstandingBytes}; " +
|
|
$"maxObservedBufferSize={metrics.MaxObservedBufferSize}; " +
|
|
$"writerErrors={writerErrors.Count}");
|
|
|
|
foreach ((int bufferSize, long count) in metrics.BufferSizeHistogram)
|
|
{
|
|
TestContext.Out.WriteLine(
|
|
$"ARRAYPOOL_DIAGNOSTIC_BUFFER scenario={scenario.Name}; " +
|
|
$"bufferSize={bufferSize}; " +
|
|
$"count={count}");
|
|
}
|
|
|
|
if (!writerErrors.IsEmpty)
|
|
{
|
|
string[] errors = [.. writerErrors
|
|
.Distinct()
|
|
.OrderBy(static value => value, StringComparer.Ordinal)
|
|
.Take(5)];
|
|
|
|
TestContext.Out.WriteLine(
|
|
"ARRAYPOOL_DIAGNOSTIC_ERRORS scenario=" +
|
|
scenario.Name +
|
|
"; samples=" +
|
|
string.Join(", ", errors));
|
|
}
|
|
}
|
|
|
|
private static long GetTotalAllocatedBytes()
|
|
{
|
|
return GC.GetTotalAllocatedBytes(true);
|
|
}
|
|
|
|
private static int RoundUpToPowerOfTwo(int value)
|
|
{
|
|
int result = 1;
|
|
|
|
while (result < value && result != 0)
|
|
{
|
|
result <<= 1;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static int GetTrackedMinimumBufferSize(int configuredMaxBufferSize)
|
|
{
|
|
int normalizedDataSize = Math.Max(
|
|
kTcpMinBufferSize,
|
|
GetSuggestedBufferSize(configuredMaxBufferSize));
|
|
|
|
return RoundUpToPowerOfTwo(normalizedDataSize + kBufferCookieLength);
|
|
}
|
|
|
|
private static int GetSuggestedBufferSize(int size)
|
|
{
|
|
int currentBucket = RoundUpToPowerOfTwo(size);
|
|
int rentBucket = RoundUpToPowerOfTwo(size + kBufferCookieLength);
|
|
|
|
if (currentBucket != rentBucket)
|
|
{
|
|
return currentBucket - kBufferCookieLength;
|
|
}
|
|
|
|
return size;
|
|
}
|
|
|
|
private sealed record ArrayPoolDiagnosticScenario(
|
|
string Name,
|
|
int PublishQueueCount,
|
|
int DurationMs,
|
|
int WriterIntervalMs,
|
|
int PublishingIntervalMs,
|
|
int SlowConsumerDelayMs,
|
|
int SlowConsumerEveryNthSession);
|
|
|
|
private sealed record ReaderSessionBundle(
|
|
ISession Session,
|
|
Subscription Subscription);
|
|
|
|
private sealed record ArrayPoolMetrics(
|
|
long RentedCount,
|
|
long ReturnedCount,
|
|
long AllocatedCount,
|
|
long DroppedCount,
|
|
long TrimmedCount,
|
|
long OutstandingCount,
|
|
long OutstandingBytes,
|
|
long PeakOutstandingCount,
|
|
long PeakOutstandingBytes,
|
|
int MaxObservedBufferSize,
|
|
IReadOnlyList<KeyValuePair<int, long>> BufferSizeHistogram);
|
|
|
|
private sealed class ArrayPoolEventListener : EventListener
|
|
{
|
|
private const string kArrayPoolEventSourceName = "System.Buffers.ArrayPoolEventSource";
|
|
|
|
private readonly int m_minTrackedBufferSize;
|
|
private readonly ConcurrentDictionary<int, long> m_bufferSizes = new();
|
|
private readonly ConcurrentDictionary<BufferKey, int> m_outstandingBuffers = new();
|
|
private long m_allocatedCount;
|
|
private long m_droppedCount;
|
|
private long m_maxObservedBufferSize;
|
|
private long m_outstandingBytes;
|
|
private long m_outstandingCount;
|
|
private long m_peakOutstandingBytes;
|
|
private long m_peakOutstandingCount;
|
|
private long m_rentedCount;
|
|
private long m_returnedCount;
|
|
private long m_trimmedCount;
|
|
private long m_lastActivityTimestamp = Stopwatch.GetTimestamp();
|
|
private EventSource? m_source;
|
|
|
|
public ArrayPoolEventListener(int minTrackedBufferSize)
|
|
{
|
|
m_minTrackedBufferSize = minTrackedBufferSize;
|
|
}
|
|
|
|
public ArrayPoolMetrics CreateMetrics()
|
|
{
|
|
IReadOnlyList<KeyValuePair<int, long>> histogram =
|
|
[
|
|
.. m_bufferSizes
|
|
.OrderBy(static value => value.Key)
|
|
.Select(static value => new KeyValuePair<int, long>(value.Key, value.Value))
|
|
];
|
|
|
|
return new ArrayPoolMetrics(
|
|
RentedCount: Interlocked.Read(ref m_rentedCount),
|
|
ReturnedCount: Interlocked.Read(ref m_returnedCount),
|
|
AllocatedCount: Interlocked.Read(ref m_allocatedCount),
|
|
DroppedCount: Interlocked.Read(ref m_droppedCount),
|
|
TrimmedCount: Interlocked.Read(ref m_trimmedCount),
|
|
OutstandingCount: Interlocked.Read(ref m_outstandingCount),
|
|
OutstandingBytes: Interlocked.Read(ref m_outstandingBytes),
|
|
PeakOutstandingCount: Interlocked.Read(ref m_peakOutstandingCount),
|
|
PeakOutstandingBytes: Interlocked.Read(ref m_peakOutstandingBytes),
|
|
MaxObservedBufferSize: (int)Interlocked.Read(ref m_maxObservedBufferSize),
|
|
BufferSizeHistogram: histogram);
|
|
}
|
|
|
|
public TimeSpan GetIdleDuration()
|
|
{
|
|
long elapsedTicks = Stopwatch.GetTimestamp() -
|
|
Interlocked.Read(ref m_lastActivityTimestamp);
|
|
|
|
return TimeSpan.FromSeconds(
|
|
(double)elapsedTicks / Stopwatch.Frequency);
|
|
}
|
|
|
|
protected override void OnEventSourceCreated(EventSource eventSource)
|
|
{
|
|
if (eventSource.Name == kArrayPoolEventSourceName)
|
|
{
|
|
m_source = eventSource;
|
|
EnableEvents(eventSource, EventLevel.Verbose);
|
|
}
|
|
}
|
|
|
|
protected override void OnEventWritten(EventWrittenEventArgs eventData)
|
|
{
|
|
string eventName = eventData.EventName ??
|
|
eventData.EventId.ToString(CultureInfo.InvariantCulture);
|
|
|
|
if (!TryGetPayloadInt32(eventData, "bufferSize", fallbackIndex: 1, out int bufferSize) ||
|
|
bufferSize < m_minTrackedBufferSize)
|
|
{
|
|
return;
|
|
}
|
|
|
|
UpdateMaxObservedBufferSize(bufferSize);
|
|
Interlocked.Exchange(ref m_lastActivityTimestamp, Stopwatch.GetTimestamp());
|
|
|
|
switch (eventName)
|
|
{
|
|
case "BufferRented":
|
|
Interlocked.Increment(ref m_rentedCount);
|
|
m_bufferSizes.AddOrUpdate(bufferSize, 1, static (_, count) => count + 1);
|
|
if (TryGetBufferKey(eventData, out BufferKey rentedKey) &&
|
|
m_outstandingBuffers.TryAdd(rentedKey, bufferSize))
|
|
{
|
|
AddOutstanding(bufferSize);
|
|
}
|
|
break;
|
|
case "BufferReturned":
|
|
if (TryGetBufferKey(eventData, out BufferKey returnedKey) &&
|
|
m_outstandingBuffers.TryRemove(returnedKey, out int rentedBufferSize))
|
|
{
|
|
Interlocked.Increment(ref m_returnedCount);
|
|
RemoveOutstanding(rentedBufferSize);
|
|
}
|
|
break;
|
|
case "BufferAllocated":
|
|
Interlocked.Increment(ref m_allocatedCount);
|
|
break;
|
|
case "BufferDropped":
|
|
Interlocked.Increment(ref m_droppedCount);
|
|
break;
|
|
case "BufferTrimmed":
|
|
Interlocked.Increment(ref m_trimmedCount);
|
|
break;
|
|
}
|
|
}
|
|
|
|
public override void Dispose()
|
|
{
|
|
if (m_source != null)
|
|
{
|
|
DisableEvents(m_source);
|
|
}
|
|
|
|
base.Dispose();
|
|
}
|
|
|
|
private static bool TryGetBufferKey(
|
|
EventWrittenEventArgs eventData,
|
|
out BufferKey key)
|
|
{
|
|
if (TryGetPayloadInt32(eventData, "bufferId", fallbackIndex: 0, out int bufferId) &&
|
|
TryGetPayloadInt32(eventData, "poolId", fallbackIndex: 2, out int poolId))
|
|
{
|
|
key = new BufferKey(poolId, bufferId);
|
|
return true;
|
|
}
|
|
|
|
key = default;
|
|
return false;
|
|
}
|
|
|
|
private static bool TryGetPayloadInt32(
|
|
EventWrittenEventArgs eventData,
|
|
string payloadName,
|
|
int fallbackIndex,
|
|
out int result)
|
|
{
|
|
result = 0;
|
|
IList<string>? payloadNames = eventData.PayloadNames;
|
|
IList<object?>? payload = eventData.Payload;
|
|
|
|
if (payload == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (payloadNames != null)
|
|
{
|
|
for (int i = 0; i < payloadNames.Count; i++)
|
|
{
|
|
if (string.Equals(
|
|
payloadNames[i],
|
|
payloadName,
|
|
StringComparison.OrdinalIgnoreCase) &&
|
|
TryConvertToInt32(payload[i], out result))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return fallbackIndex < payload.Count &&
|
|
TryConvertToInt32(payload[fallbackIndex], out result);
|
|
}
|
|
|
|
private static bool TryConvertToInt32(object? value, out int result)
|
|
{
|
|
switch (value)
|
|
{
|
|
case int int32:
|
|
result = int32;
|
|
return true;
|
|
case long int64 when int64 is <= int.MaxValue and >= int.MinValue:
|
|
result = (int)int64;
|
|
return true;
|
|
default:
|
|
result = 0;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private void AddOutstanding(int bufferSize)
|
|
{
|
|
long outstandingCount = Interlocked.Increment(ref m_outstandingCount);
|
|
long outstandingBytes = Interlocked.Add(ref m_outstandingBytes, bufferSize);
|
|
UpdatePeak(ref m_peakOutstandingCount, outstandingCount);
|
|
UpdatePeak(ref m_peakOutstandingBytes, outstandingBytes);
|
|
}
|
|
|
|
private void RemoveOutstanding(int bufferSize)
|
|
{
|
|
Interlocked.Decrement(ref m_outstandingCount);
|
|
Interlocked.Add(ref m_outstandingBytes, -bufferSize);
|
|
}
|
|
|
|
private void UpdateMaxObservedBufferSize(int bufferSize)
|
|
{
|
|
long current;
|
|
do
|
|
{
|
|
current = Interlocked.Read(ref m_maxObservedBufferSize);
|
|
if (bufferSize <= current)
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
while (Interlocked.CompareExchange(
|
|
ref m_maxObservedBufferSize,
|
|
bufferSize,
|
|
current) != current);
|
|
}
|
|
|
|
private static void UpdatePeak(ref long peak, long candidate)
|
|
{
|
|
long current;
|
|
do
|
|
{
|
|
current = Interlocked.Read(ref peak);
|
|
if (candidate <= current)
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
while (Interlocked.CompareExchange(ref peak, candidate, current) != current);
|
|
}
|
|
|
|
private readonly record struct BufferKey(int PoolId, int BufferId);
|
|
}
|
|
}
|
|
}
|
|
#endif
|