Files
UA-.NETStandard/tests/Opc.Ua.Server.TestFramework/ServerFixture.cs
2026-07-16 06:25:54 +02:00

721 lines
30 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ========================================================================
* 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.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Opc.Ua.Configuration;
using Opc.Ua.Tests;
using Quickstarts.ReferenceServer;
namespace Opc.Ua.Server.TestFramework
{
/// <summary>
/// Server fixture for testing.
/// </summary>
/// <typeparam name="T">A server class T used for testing.</typeparam>
// CA1001: test fixture; lifecycle is handled via async StopAsync
// (Application.DisposeAsync at line 445), not the IDisposable pattern.
#pragma warning disable CA1001
public class ServerFixture<T>
#pragma warning restore CA1001
where T : ServerBase
{
public IApplicationInstance Application { get; private set; }
public ApplicationConfiguration Config { get; private set; }
public T Server { get; private set; }
public bool AutoAccept { get; set; }
public bool OperationLimits { get; set; }
public int MaxChannelCount { get; set; } = 10;
public int ReverseConnectTimeout { get; set; }
public bool AllNodeManagers { get; set; }
public int TraceMasks { get; set; } =
Utils.TraceMasks.Error |
Utils.TraceMasks.StackTrace |
Utils.TraceMasks.Security |
Utils.TraceMasks.Information;
public bool SecurityNone { get; set; }
public string UriScheme { get; set; } = Utils.UriSchemeOpcTcp;
/// <summary>
/// When <c>true</c> (default), HTTPS / WSS endpoints require client
/// TLS authentication and the listener emits the configured
/// secure security policy. When <c>false</c>, mutual TLS is off and
/// the listener emits a <see cref="MessageSecurityMode.None"/>
/// endpoint - required for the JSON sub-protocols
/// (HTTPS-JSON, WSS-JSON) which do not use UA Secure Conversation.
/// </summary>
public bool HttpsMutualTls { get; set; } = true;
public int Port { get; private set; }
public bool UseTracing { get; }
public bool DurableSubscriptionsEnabled { get; set; }
public bool UseSamplingGroupsInReferenceNodeManager { get; set; }
public bool ProvisioningMode { get; set; }
public ActivityListener ActivityListener { get; private set; }
/// <summary>
/// Optional <see cref="Bindings.ITransportBindingRegistry"/>
/// assigned to the server immediately after construction (before
/// <c>StartAsync</c>). Used by integration tests that swap a
/// listener / channel factory for a binding under test - e.g. the
/// Kestrel-TCP listener fixture - without touching the
/// process-wide static state.
/// </summary>
public Bindings.ITransportBindingRegistry TransportBindingRegistry { get; set; }
public ServerFixture(
Func<ITelemetryContext, T> factory,
bool useTracing,
bool disableActivityLogging)
: this(factory)
{
UseTracing = useTracing;
if (UseTracing)
{
StartActivityListenerInternal(disableActivityLogging);
}
}
public ServerFixture(Func<ITelemetryContext, T> factory)
{
m_factory = factory;
m_telemetry = NUnitTelemetryContext.Create(true);
m_logger = m_telemetry.CreateLogger<ServerFixture<T>>();
}
public async Task LoadConfigurationAsync(string pkiRoot = null)
{
Application = new ApplicationInstance(m_telemetry)
{
ApplicationName = typeof(T).Name,
ApplicationType = ApplicationType.Server
};
// create the application configuration. Use temp path for cert stores.
pkiRoot ??= Path.GetTempPath() + Path.GetRandomFileName();
string endpointUrl = $"{UriScheme}://localhost:0/" + typeof(T).Name;
IApplicationConfigurationBuilderServerSelected serverConfig = Application
.Build(
"urn:localhost:UA:" + typeof(T).Name,
"uri:opcfoundation.org:" + typeof(T).Name)
.SetMaxByteStringLength(4 * 1024 * 1024)
.SetMaxArrayLength(1024 * 1024)
.SetMaxMessageSize(16 * 1024 * 1024)
.SetChannelLifetime(30000)
.AsServer([endpointUrl]);
if (SecurityNone)
{
serverConfig.AddUnsecurePolicyNone();
}
if (Utils.IsUriHttpsScheme(endpointUrl) || Utils.IsUriWssScheme(endpointUrl))
{
serverConfig.AddPolicy(
MessageSecurityMode.SignAndEncrypt,
SecurityPolicies.Basic256Sha256);
}
else if (endpointUrl.StartsWith(Utils.UriSchemeOpcTcp, StringComparison.Ordinal))
{
// add deprecated policies for opc.tcp tests
serverConfig
.AddSignPolicies()
.AddSignAndEncryptPolicies()
.AddEccSignPolicies()
.AddEccSignAndEncryptPolicies();
AddPolicyIfSupported(
serverConfig,
MessageSecurityMode.Sign,
SecurityPolicies.Basic128Rsa15);
AddPolicyIfSupported(
serverConfig,
MessageSecurityMode.SignAndEncrypt,
SecurityPolicies.Basic128Rsa15);
AddPolicyIfSupported(
serverConfig,
MessageSecurityMode.Sign,
SecurityPolicies.Basic256);
AddPolicyIfSupported(
serverConfig,
MessageSecurityMode.SignAndEncrypt,
SecurityPolicies.Basic256);
AddPolicyIfSupported(
serverConfig,
MessageSecurityMode.Sign,
SecurityPolicies.RSA_DH_AesGcm);
AddPolicyIfSupported(
serverConfig,
MessageSecurityMode.SignAndEncrypt,
SecurityPolicies.RSA_DH_AesGcm);
AddPolicyIfSupported(
serverConfig,
MessageSecurityMode.Sign,
SecurityPolicies.RSA_DH_ChaChaPoly);
AddPolicyIfSupported(
serverConfig,
MessageSecurityMode.SignAndEncrypt,
SecurityPolicies.RSA_DH_ChaChaPoly);
AddPolicyIfSupported(
serverConfig,
MessageSecurityMode.Sign,
SecurityPolicies.ECC_nistP256_AesGcm);
AddPolicyIfSupported(
serverConfig,
MessageSecurityMode.SignAndEncrypt,
SecurityPolicies.ECC_nistP256_AesGcm);
AddPolicyIfSupported(
serverConfig,
MessageSecurityMode.Sign,
SecurityPolicies.ECC_nistP256_ChaChaPoly);
AddPolicyIfSupported(
serverConfig,
MessageSecurityMode.SignAndEncrypt,
SecurityPolicies.ECC_nistP256_ChaChaPoly);
AddPolicyIfSupported(
serverConfig,
MessageSecurityMode.Sign,
SecurityPolicies.ECC_nistP384_AesGcm);
AddPolicyIfSupported(
serverConfig,
MessageSecurityMode.SignAndEncrypt,
SecurityPolicies.ECC_nistP384_AesGcm);
AddPolicyIfSupported(
serverConfig,
MessageSecurityMode.Sign,
SecurityPolicies.ECC_nistP384_ChaChaPoly);
AddPolicyIfSupported(
serverConfig,
MessageSecurityMode.SignAndEncrypt,
SecurityPolicies.ECC_nistP384_ChaChaPoly);
AddPolicyIfSupported(
serverConfig,
MessageSecurityMode.Sign,
SecurityPolicies.ECC_brainpoolP256r1_AesGcm);
AddPolicyIfSupported(
serverConfig,
MessageSecurityMode.SignAndEncrypt,
SecurityPolicies.ECC_brainpoolP256r1_AesGcm);
AddPolicyIfSupported(
serverConfig,
MessageSecurityMode.Sign,
SecurityPolicies.ECC_brainpoolP256r1_ChaChaPoly);
AddPolicyIfSupported(
serverConfig,
MessageSecurityMode.SignAndEncrypt,
SecurityPolicies.ECC_brainpoolP256r1_ChaChaPoly);
AddPolicyIfSupported(
serverConfig,
MessageSecurityMode.Sign,
SecurityPolicies.ECC_brainpoolP384r1_AesGcm);
AddPolicyIfSupported(
serverConfig,
MessageSecurityMode.SignAndEncrypt,
SecurityPolicies.ECC_brainpoolP384r1_AesGcm);
AddPolicyIfSupported(
serverConfig,
MessageSecurityMode.Sign,
SecurityPolicies.ECC_brainpoolP384r1_ChaChaPoly);
AddPolicyIfSupported(
serverConfig,
MessageSecurityMode.SignAndEncrypt,
SecurityPolicies.ECC_brainpoolP384r1_ChaChaPoly);
}
if (OperationLimits)
{
serverConfig.SetOperationLimits(
new OperationLimits
{
MaxNodesPerBrowse = 2500,
MaxNodesPerRead = 1000,
MaxNodesPerWrite = 1000,
MaxNodesPerMethodCall = 1000,
MaxMonitoredItemsPerCall = 1000,
MaxNodesPerHistoryReadData = 1000,
MaxNodesPerHistoryReadEvents = 1000,
MaxNodesPerHistoryUpdateData = 1000,
MaxNodesPerHistoryUpdateEvents = 1000,
MaxNodesPerNodeManagement = 1000,
MaxNodesPerRegisterNodes = 1000,
MaxNodesPerTranslateBrowsePathsToNodeIds = 1000
});
}
serverConfig
.SetMaxChannelCount(MaxChannelCount)
.SetMaxMessageQueueSize(20)
.SetDiagnosticsEnabled(true)
.SetAuditingEnabled(true)
.SetHttpsMutualTls(HttpsMutualTls)
// Tests always close the session explicitly before stopping the server,
// so no real client needs the shutdown-delay grace period. Setting to 0
// eliminates the Thread.Sleep(1000)×ShutdownDelay blocking call in
// StandardServer.ShutDownDelay(), which otherwise races with the
// ServerFixture teardown watchdog (both default to 5 s) and causes a
// post-test process hang on macOS where Thread.Sleep drifts slightly
// above 1 s per iteration.
.SetShutdownDelay(0);
if (ReverseConnectTimeout != 0)
{
serverConfig.SetReverseConnect(
new ReverseConnectServerConfiguration
{
ConnectInterval = ReverseConnectTimeout / 4,
ConnectTimeout = ReverseConnectTimeout,
RejectTimeout = ReverseConnectTimeout / 4
});
}
ArrayOf<CertificateIdentifier> applicationCerts =
ApplicationConfigurationBuilder.CreateDefaultApplicationCertificates(
"CN=" + typeof(T).Name + ", C=US, S=Arizona, O=OPC Foundation, DC=localhost",
CertificateStoreType.Directory,
pkiRoot);
if (DurableSubscriptionsEnabled)
{
serverConfig
.SetDurableSubscriptionsEnabled(true)
.SetMaxDurableEventQueueSize(10000)
.SetMaxDurableNotificationQueueSize(1000)
.SetMaxDurableSubscriptionLifetime(3600);
}
Config = await serverConfig
.AddSecurityConfiguration(applicationCerts, pkiRoot)
.SetAutoAcceptUntrustedCertificates(AutoAccept)
.CreateAsync()
.ConfigureAwait(false);
}
/// <summary>
/// Start server fixture on random or fixed port.
/// </summary>
public Task<T> StartAsync(int port = 0)
{
return StartAsync(null, port);
}
/// <summary>
/// Start server fixture on random or fixed port with dedicated PKI.
/// </summary>
public async Task<T> StartAsync(string pkiRoot, int port = 0)
{
int testPort = port;
int serverStartRetries = 1;
if (Application == null)
{
await LoadConfigurationAsync(pkiRoot).ConfigureAwait(false);
}
if (port <= 0)
{
testPort = ServerFixtureUtils.GetNextFreeIPPort();
serverStartRetries = 25;
}
bool retryStartServer;
do
{
retryStartServer = false;
try
{
await InternalStartServerAsync(testPort).ConfigureAwait(false);
}
catch (ServiceResultException sre)
when (serverStartRetries > 0 &&
sre.StatusCode == StatusCodes.BadNoCommunication)
{
serverStartRetries--;
testPort = UnsecureRandom.Shared.Next(
ServerFixtureUtils.MinTestPort,
ServerFixtureUtils.MaxTestPort);
retryStartServer = true;
}
await Task.Delay(UnsecureRandom.Shared.Next(100, 1000)).ConfigureAwait(false);
} while (retryStartServer);
return Server;
}
/// <summary>
/// Create the configuration and start the server.
/// </summary>
/// <exception cref="InvalidOperationException"></exception>
private async Task InternalStartServerAsync(int port)
{
Config.ServerConfiguration.BaseAddresses
= [$"{UriScheme}://localhost:{port}/{typeof(T).Name}"];
// check the application certificate.
bool haveAppCertificate = await Application
.CheckApplicationInstanceCertificatesAsync(true, CertificateFactory.DefaultLifeTime)
.ConfigureAwait(false);
if (!haveAppCertificate)
{
throw new InvalidOperationException("Application instance certificate invalid!");
}
// start the server.
T server = m_factory(m_telemetry);
server.TransportBindings = TransportBindingRegistry
?? TestTransportBindings.WithAllSchemes();
if (AllNodeManagers && server is StandardServer standardServer)
{
Quickstarts.Servers.Utils.AddDefaultNodeManagers(standardServer);
}
if (UseSamplingGroupsInReferenceNodeManager &&
server is ReferenceServer referenceServer)
{
Quickstarts.Servers.Utils.UseSamplingGroupsInReferenceNodeManager(referenceServer);
}
if (ProvisioningMode &&
server is ReferenceServer provisioningReferenceServer)
{
Quickstarts.Servers.Utils.EnableProvisioningMode(provisioningReferenceServer);
}
m_startupCts = new CancellationTokenSource();
await Application.StartAsync(server, m_startupCts.Token).ConfigureAwait(false);
Server = server;
Port = port;
}
/// <summary>
/// Configures Activity Listener and registers with Activity Source.
/// </summary>
public void StartActivityListenerInternal(bool disableActivityLogging = false)
{
if (disableActivityLogging)
{
// Create an instance of ActivityListener without logging
ActivityListener = new ActivityListener
{
ShouldListenTo = (source) => source.Name == m_telemetry.GetActivitySource().Name,
Sample = (ref _) => ActivitySamplingResult.AllDataAndRecorded,
ActivityStarted = _ => { },
ActivityStopped = _ => { }
};
}
else
{
// Create an instance of ActivityListener and configure its properties with logging
ActivityListener = new ActivityListener
{
ShouldListenTo = (source) => source.Name == m_telemetry.GetActivitySource().Name,
Sample = (ref _) => ActivitySamplingResult.AllDataAndRecorded,
ActivityStarted = activity =>
{
if (m_logger.IsEnabled(LogLevel.Information))
{
m_logger.LogInformation(
"Server Started: {OperationName,-15} - TraceId: {TraceId,-32} SpanId: {SpanId,-16} ParentId: {ParentId,-32}",
activity.OperationName,
activity.TraceId,
activity.SpanId,
activity.ParentId);
}
},
ActivityStopped = activity =>
{
if (m_logger.IsEnabled(LogLevel.Information))
{
m_logger.LogInformation(
"Server Stopped: {OperationName,-15} - TraceId: {TraceId,-32} SpanId: {SpanId,-16} ParentId: {ParentId,-32} Duration: {Duration}",
activity.OperationName,
activity.TraceId,
activity.SpanId,
activity.ParentId,
activity.Duration);
}
}
};
}
ActivitySource.AddActivityListener(ActivityListener);
}
/// <summary>
/// Stop the server.
/// </summary>
public async Task StopAsync()
{
// Cancel any in-progress startup (e.g., address space creation)
m_startupCts?.Dispose();
m_startupCts = null;
// Watchdog around server / application teardown. Without it, a
// stuck Server.StopAsync() or Application.DisposeAsync() blocks
// the per-fixture OneTimeTearDown indefinitely, and the entire
// dotnet test host hangs until the --blame-hang-timeout fires
// (observed in macOS net10.0 CI runs of Opc.Ua.History.Tests:
// all 466+ tests pass, then a 10-minute teardown hang followed
// by a hang dump and exit code 1). Mirrors the bounded-thread
// pattern in LeakDetectionHelpers.TryRunFinalizerSweep so a
// process-killing hang becomes an observable per-fixture
// warning while subsequent fixtures' teardowns and the dotnet
// host shutdown can still complete.
//
// The per-call timeout is kept tight (5s) because each test
// project has up to 27 fixtures; a 30s budget per call would
// exceed the 10-minute dotnet test hang timeout once even a
// handful of fixtures hit the watchdog in series. A graceful
// shutdown in the healthy case completes in well under 5s.
// The same timeout is propagated as a CancellationToken so the
// server can short-circuit instead of being abandoned (the
// server StopAsync overload threads the token through
// RegisterWithDiscoveryServerAsync, the shutdown semaphore,
// SubscriptionManager.ShutdownAsync and
// NodeManager.ShutdownAsync).
//
// GLOBAL FAST-PATH: once any fixture's teardown exceeds the
// watchdog in this test process, set a static "give up
// teardown" flag and short-circuit subsequent fixtures'
// teardowns to a no-op. This converts the worst case from
// (27 fixtures x N watchdog operations x 5s) into a single
// ~10s teardown stall: only the first hung fixture pays the
// watchdog cost; the rest skip directly to the next test
// host shutdown step. We accept the resource leak as the
// test process is about to exit.
if (s_skipRemainingTeardowns)
{
m_logger.LogWarning(
"ServerFixture.StopAsync: a prior fixture teardown timed out; " +
"skipping the remaining server / application disposal in this process to " +
"avoid pinning the dotnet test host past --blame-hang-timeout. " +
"References will be released to the runtime for finalization.");
DisposeCertificateManagers();
Server = null;
Application = null;
Config = null;
ActivityListener?.Dispose();
ActivityListener = null;
return;
}
if (Server != null)
{
using var serverStopCts = new CancellationTokenSource(s_teardownTimeout);
await RunWithTeardownWatchdogAsync(
() => Server.StopAsync(serverStopCts.Token).AsTask(),
nameof(Server) + "." + nameof(Server.StopAsync)).ConfigureAwait(false);
RunSyncWithTeardownWatchdog(
() => Server.Dispose(),
nameof(Server) + "." + nameof(Server.Dispose));
DisposeCertificateManagers();
Server = null;
}
if (Application != null)
{
try
{
await RunWithTeardownWatchdogAsync(
() => Application.DisposeAsync().AsTask(),
nameof(Application) + "." + nameof(Application.DisposeAsync)).ConfigureAwait(false);
}
finally
{
DisposeCertificateManagers();
Application = null;
}
}
Config = null;
ActivityListener?.Dispose();
ActivityListener = null;
await Task.Delay(100).ConfigureAwait(false);
}
private void DisposeCertificateManagers()
{
var applicationManager = Application?.ApplicationConfiguration?.CertificateManager as IDisposable;
applicationManager?.Dispose();
var configManager = Config?.CertificateManager as IDisposable;
if (configManager != null &&
!ReferenceEquals(configManager, applicationManager))
{
configManager.Dispose();
}
if (Server?.CertificateManager is IDisposable serverManager &&
!ReferenceEquals(serverManager, applicationManager) &&
!ReferenceEquals(serverManager, configManager))
{
serverManager.Dispose();
}
}
private static readonly TimeSpan s_teardownTimeout = TimeSpan.FromSeconds(5);
/// <summary>
/// Set by the first <see cref="RunWithTeardownWatchdogAsync"/> or
/// <see cref="RunSyncWithTeardownWatchdog"/> invocation that
/// exceeds the watchdog. Subsequent fixtures' <see cref="StopAsync"/>
/// calls short-circuit to a no-op so we do not stack N x watchdog
/// timeouts during a known-bad teardown chain.
/// </summary>
private static volatile bool s_skipRemainingTeardowns;
/// <summary>
/// Runs <paramref name="taskFactory"/> bounded by
/// <see cref="s_teardownTimeout"/>. If the task does not complete
/// in time, logs a warning, sets <see cref="s_skipRemainingTeardowns"/>
/// so subsequent fixtures short-circuit, and returns; the
/// underlying task is abandoned (it is a background continuation
/// in the runtime thread pool, so it does not block process
/// exit). Cross-TFM compatible: avoids
/// <c>Task.WaitAsync(TimeSpan)</c> which is .NET 6+; the test
/// framework targets net48 and netstandard2.0 among others.
/// </summary>
private async Task RunWithTeardownWatchdogAsync(
Func<Task> taskFactory,
string operationName)
{
Task work;
try
{
work = taskFactory();
}
catch (Exception ex)
{
m_logger.LogWarning(
ex,
"ServerFixture.StopAsync: {Operation} threw synchronously.",
operationName);
return;
}
Task winner = await Task
.WhenAny(work, Task.Delay(s_teardownTimeout))
.ConfigureAwait(false);
if (winner != work)
{
s_skipRemainingTeardowns = true;
m_logger.LogWarning(
"ServerFixture.StopAsync: {Operation} exceeded the {TimeoutSeconds}s teardown watchdog. " +
"Continuing with the rest of the fixture teardown; subsequent fixtures will short-circuit.",
operationName,
s_teardownTimeout.TotalSeconds);
return;
}
try
{
await work.ConfigureAwait(false);
}
catch (Exception ex)
{
m_logger.LogWarning(
ex,
"ServerFixture.StopAsync: {Operation} faulted.",
operationName);
}
}
/// <summary>
/// Runs a synchronous <paramref name="action"/> on a background
/// thread bounded by <see cref="s_teardownTimeout"/>. Used for
/// synchronous <c>Dispose()</c> calls that can block on socket
/// shutdown or thread joins; the async watchdog above only
/// bounds <see cref="Task"/>-returning calls and cannot interrupt
/// a sync method that ignores cancellation.
/// </summary>
private void RunSyncWithTeardownWatchdog(Action action, string operationName)
{
using var done = new ManualResetEventSlim(false);
Exception captured = null;
var worker = new Thread(() =>
{
try
{
action();
}
catch (Exception ex)
{
captured = ex;
}
finally
{
done.Set();
}
})
{
IsBackground = true,
Name = "ServerFixture.SyncTeardown." + operationName
};
worker.Start();
if (!done.Wait(s_teardownTimeout))
{
s_skipRemainingTeardowns = true;
m_logger.LogWarning(
"ServerFixture.StopAsync: {Operation} exceeded the {TimeoutSeconds}s teardown watchdog (sync). " +
"Continuing with the rest of the fixture teardown; subsequent fixtures will short-circuit.",
operationName,
s_teardownTimeout.TotalSeconds);
return;
}
if (captured != null)
{
m_logger.LogWarning(
captured,
"ServerFixture.StopAsync: {Operation} threw.",
operationName);
}
}
private static void AddPolicyIfSupported(
IApplicationConfigurationBuilderServerSelected serverConfig,
MessageSecurityMode securityMode,
string securityPolicyUri)
{
if (SecurityPolicies.GetInfo(securityPolicyUri) != null)
{
serverConfig.AddPolicy(securityMode, securityPolicyUri);
}
}
private CancellationTokenSource m_startupCts;
private readonly Func<ITelemetryContext, T> m_factory;
private readonly ITelemetryContext m_telemetry;
private readonly ILogger m_logger;
}
}