From 60037bc124a92b6c30823a56b59c08622874353d Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Wed, 2 Sep 2026 12:19:45 -0700 Subject: [PATCH 01/12] Add SqlClient agent identifier to USERAGENT payload Adds an optional agent identifier to the USERAGENT login feature extension so known middleware (EF Core, SSMS, DacFx, ...) can be told apart from direct SqlClient use. - New public `SqlClientAgent` enum and `SqlConnection.RegisterSqlClientAgent(SqlClientAgent)`. - Registration is process-wide and allowed once, so an application cannot overwrite or spoof an agent set by a library. - Can also be set from App.config via a `SqlClientAgent` section. - Payload format bumped to version 2; the agent id is appended as an optional 8th part only when registered. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: efbed43f-1014-45d7-a9f6-9f04711c281e --- .../SqlConnection_RegisterSqlClientAgent.cs | 13 + .../SqlConnection.xml | 41 ++++ .../ref/Microsoft.Data.SqlClient.cs | 33 +++ .../Data/SqlClient/SqlClientAgent.cs | 230 ++++++++++++++++++ .../Microsoft/Data/SqlClient/SqlConnection.cs | 5 + .../src/Microsoft/Data/SqlClient/SqlUtil.cs | 10 + .../src/Microsoft/Data/SqlClient/TdsParser.cs | 4 +- .../src/Microsoft/Data/SqlClient/UserAgent.cs | 133 +++++++++- .../src/Resources/Strings.Designer.cs | 18 ++ .../src/Resources/Strings.resx | 6 + .../SqlClientAgentConfigurationTests.cs | 26 ++ .../tests/FunctionalTests/app.config | 2 + .../SimulatedServerTests/ConnectionTests.cs | 17 +- .../tests/UnitTests/SqlClientAgentTests.cs | 112 +++++++++ .../tests/UnitTests/UserAgentTests.cs | 80 +++++- 15 files changed, 709 insertions(+), 21 deletions(-) create mode 100644 doc/samples/SqlConnection_RegisterSqlClientAgent.cs create mode 100644 src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientAgent.cs create mode 100644 src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlClientAgentConfigurationTests.cs create mode 100644 src/Microsoft.Data.SqlClient/tests/UnitTests/SqlClientAgentTests.cs diff --git a/doc/samples/SqlConnection_RegisterSqlClientAgent.cs b/doc/samples/SqlConnection_RegisterSqlClientAgent.cs new file mode 100644 index 0000000000..3d800ed1d8 --- /dev/null +++ b/doc/samples/SqlConnection_RegisterSqlClientAgent.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Data.SqlClient; + +internal static class MiddlewareRegistration +{ + // Register once during application startup, before opening any connections. + internal static void Register() + { + SqlConnection.RegisterSqlClientAgent(SqlClientAgent.EntityFramework); + } +} diff --git a/doc/snippets/Microsoft.Data.SqlClient/SqlConnection.xml b/doc/snippets/Microsoft.Data.SqlClient/SqlConnection.xml index 5f88a1498e..b311579a97 100644 --- a/doc/snippets/Microsoft.Data.SqlClient/SqlConnection.xml +++ b/doc/snippets/Microsoft.Data.SqlClient/SqlConnection.xml @@ -2294,6 +2294,47 @@ The following sample tries to open a connection to an invalid database to simula + + + Globally registers the middleware agent for physical connections opened by Microsoft.Data.SqlClient. + + The positive numeric identifier assigned to the middleware agent. + + The numeric value of is zero. + + + An agent was already registered programmatically or through application configuration. + + + [!code-csharp[Register an agent](~/../sqlclient/doc/samples/SqlConnection_RegisterSqlClientAgent.cs)] + + + + This API is intended only for approved middleware partners. Applications should not call it directly. + + + Register the agent once during application startup, before opening any connections. The first registration + applies process-wide and cannot be replaced. Existing physical connections are not updated after registration. + + + An agent can instead be registered from an application configuration file. Configuration is loaded before + programmatic registration and therefore takes precedence: + + + <configuration> + <configSections> + <section name="SqlClientAgent" + type="Microsoft.Data.SqlClient.SqlClientAgentConfigurationSection,Microsoft.Data.SqlClient" /> + </configSections> + <SqlClientAgent id="EntityFramework" /> + </configuration> + + + The id value can be an enum member name or a positive numeric value. Numeric values not declared by + are accepted for forward compatibility. + + + Gets a string that identifies the database client. diff --git a/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs b/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs index 266d0b9a1d..fb2c9aabec 100644 --- a/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs +++ b/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs @@ -592,6 +592,36 @@ public void LogError(string type, string method, string message) { } public void LogInfo(string type, string method, string message) { } } +/// +/// Identifies known middleware agents that use Microsoft.Data.SqlClient. +/// +[System.CLSCompliantAttribute(false)] +public enum SqlClientAgent : ushort +{ + /// The Microsoft Entity Framework Core SQL Server provider. + EntityFramework = 1, + /// Microsoft Semantic Kernel. + SemanticKernel = 2, + /// Microsoft SQL Server Management Studio. + ManagementStudio = 3, + /// Microsoft SQL Server Management Objects. + SqlManagementObjects = 4, + /// Microsoft SQL Server Data-Tier Application Framework. + DataTierApplicationFramework = 5, + /// Microsoft SQL Tools Service. + SqlToolsService = 6, + /// Microsoft ASP.NET Core distributed SQL Server cache. + AspNetCoreDistributedSqlServerCache = 7, + /// Microsoft Entity Framework 6 SQL Server provider. + EntityFramework6 = 8, + /// Microsoft Azure Functions SQL extension. + AzureFunctionsSqlExtension = 9, + /// Microsoft Orleans ADO.NET providers. + OrleansAdoNet = 10, + /// Microsoft Durable Task SQL Server provider. + DurableTaskSqlServer = 11 +} + /// public static class SqlClientMetaDataCollectionNames { @@ -1010,6 +1040,9 @@ public SqlConnection() { } public SqlConnection(string connectionString) { } /// public SqlConnection(string connectionString, Microsoft.Data.SqlClient.SqlCredential credential) { } + /// + [System.CLSCompliantAttribute(false)] + public static void RegisterSqlClientAgent(Microsoft.Data.SqlClient.SqlClientAgent id) { } /// [System.ComponentModel.BrowsableAttribute(false)] diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientAgent.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientAgent.cs new file mode 100644 index 0000000000..61fe567d14 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientAgent.cs @@ -0,0 +1,230 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Configuration; +using System.Threading; +using Microsoft.Data.Common; +using Microsoft.Data.SqlClient.Internal; + +#nullable enable + +namespace Microsoft.Data.SqlClient; + +/// +/// Identifies known middleware agents that use Microsoft.Data.SqlClient. +/// +[CLSCompliant(false)] +public enum SqlClientAgent : ushort +{ + /// The Microsoft Entity Framework Core SQL Server provider. + EntityFramework = 1, + + /// Microsoft Semantic Kernel. + SemanticKernel = 2, + + /// Microsoft SQL Server Management Studio. + ManagementStudio = 3, + + /// Microsoft SQL Server Management Objects. + SqlManagementObjects = 4, + + /// Microsoft SQL Server Data-Tier Application Framework. + DataTierApplicationFramework = 5, + + /// Microsoft SQL Tools Service. + SqlToolsService = 6, + + /// Microsoft ASP.NET Core distributed SQL Server cache. + AspNetCoreDistributedSqlServerCache = 7, + + /// Microsoft Entity Framework 6 SQL Server provider. + EntityFramework6 = 8, + + /// Microsoft Azure Functions SQL extension. + AzureFunctionsSqlExtension = 9, + + /// Microsoft Orleans ADO.NET providers. + OrleansAdoNet = 10, + + /// Microsoft Durable Task SQL Server provider. + DurableTaskSqlServer = 11 +} + +/// +/// +/// Holds the process-wide agent registration reported in the USERAGENT +/// login feature extension. +/// +/// +/// An agent may be registered at most once per process, either from the +/// application configuration file or programmatically via +/// . The configuration +/// file wins, because it is read during static construction, before any +/// application code can call +/// . +/// +/// +internal static class SqlClientAgentRegistration +{ + // The registered agent identifier, or 0 when no agent is registered. + // + // Written at most once, either by static construction from the application + // configuration file, or by the first Register() call. + private static int s_agentId = LoadFromAppConfig(); + + /// + /// The registered agent, or null when no agent has been registered. + /// + internal static SqlClientAgent? Agent + { + get + { + int id = Volatile.Read(ref s_agentId); + return id == 0 ? null : (SqlClientAgent)id; + } + } + + /// + /// Register the given agent for the lifetime of the process. + /// + /// The agent to register. + /// + /// is not a valid agent identifier. + /// + /// + /// An agent has already been registered. + /// + internal static void Register(SqlClientAgent id) + { + Validate(id); + if (Interlocked.CompareExchange(ref s_agentId, (ushort)id, 0) != 0) + { + throw SQL.SqlClientAgentAlreadyRegistered(); + } + } + + /// + /// + /// Convert a configured agent identifier to a + /// . + /// + /// + /// Both the name of a known agent (case-insensitive) and its numeric + /// identifier are accepted. A numeric identifier that is not yet + /// defined in is accepted, so an agent + /// assigned an identifier after this driver shipped can still be + /// configured. + /// + /// + /// The agent identifier to convert. + /// The agent the identifier names. + /// + /// is not a valid agent identifier. + /// + internal static SqlClientAgent Parse(string value) + { + // Enum.TryParse accepts comma-separated lists and combines them, so + // "EntityFramework,SemanticKernel" would silently yield an unrelated + // agent. Only a single name or number is valid here. + if (value is null + || value.IndexOf(',') >= 0 + || !Enum.TryParse(value, ignoreCase: true, out SqlClientAgent id) + || (ushort)id == 0) + { + throw SQL.InvalidSqlClientAgent(value ?? string.Empty, nameof(value)); + } + + return id; + } + + /// + /// Throw if the given agent is not a valid agent identifier. + /// + /// The agent to validate. + /// + /// is not a valid agent identifier. + /// + private static void Validate(SqlClientAgent id) + { + if ((ushort)id == 0) + { + throw SQL.InvalidSqlClientAgent(id.ToString(), nameof(id)); + } + } + + /// + /// + /// Read the agent registered in the application configuration file. + /// + /// All known exceptions are consumed. + /// + /// + /// The configured agent identifier, or 0 when no valid agent is + /// configured. + /// + private static int LoadFromAppConfig() + { + // This runs during static initialization on the login path, so any + // escaping exception would surface as a TypeInitializationException on + // every connection attempt. Telemetry must never break connections. + try + { + object section = ConfigurationManager.GetSection(SqlClientAgentConfigurationSection.Name); + if (section is null) + { + return 0; + } + + if (section is SqlClientAgentConfigurationSection configurationSection) + { + return (ushort)Parse(configurationSection.Id); + } + + SqlClientEventSource.Log.TryTraceEvent( + "SqlClientAgentRegistration: The SqlClientAgent configuration section has an unexpected type; the agent was not registered."); + } + catch (Exception e) when (ADP.IsCatchableExceptionType(e)) + { + SqlClientEventSource.Log.TryTraceEvent( + "SqlClientAgentRegistration: Unable to load the SqlClientAgent configuration; the agent was not registered: {0}", + e); + } + + return 0; + } +} + +/// +/// +/// The SqlClientAgent application configuration file section, used +/// to register an agent without changing application code. +/// +/// +/// +/// <configSections> +/// <section name="SqlClientAgent" +/// type="Microsoft.Data.SqlClient.SqlClientAgentConfigurationSection, Microsoft.Data.SqlClient" /> +/// </configSections> +/// <SqlClientAgent id="EntityFramework" /> +/// +/// +/// +internal sealed class SqlClientAgentConfigurationSection : ConfigurationSection +{ + /// + /// The name of this configuration section. + /// + internal const string Name = "SqlClientAgent"; + + /// + /// The name or numeric identifier of the agent to register. + /// + [ConfigurationProperty("id", IsRequired = true)] + public string Id + { + get => this["id"] as string ?? string.Empty; + set => this["id"] = value; + } +} diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs index b8b693132e..7ebd663e03 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs @@ -381,6 +381,11 @@ public static void RegisterColumnEncryptionKeyStoreProviders(IDictionary + [CLSCompliant(false)] + public static void RegisterSqlClientAgent(SqlClientAgent id) + => SqlClientAgentRegistration.Register(id); + /// public void RegisterColumnEncryptionKeyStoreProvidersOnConnection(IDictionary customProviders) { diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlUtil.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlUtil.cs index c9388a42f1..613dce226f 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlUtil.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlUtil.cs @@ -1856,6 +1856,16 @@ internal static Exception EmptyProviderName() #endregion Always Encrypted Errors + internal static Exception SqlClientAgentAlreadyRegistered() + { + return ADP.InvalidOperation(StringsHelper.GetString(Strings.SQL_SqlClientAgentAlreadyRegistered)); + } + + internal static Exception InvalidSqlClientAgent(string value, string parameterName) + { + return ADP.ArgumentOutOfRange(StringsHelper.GetString(Strings.SQL_InvalidSqlClientAgent, value), parameterName); + } + // // Merged Provider // diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs index d8c904f1e0..f385462612 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs @@ -1361,7 +1361,7 @@ internal void TdsLogin( requestedFeatures, recoverySessionData, fedAuthFeatureExtensionData, - UserAgent.Ucs2Bytes, + UserAgent.GetUcs2Bytes(SqlClientAgentRegistration.Agent), useFeatureExt, length ); @@ -9521,7 +9521,7 @@ private void WriteLoginData(SqlLogin rec, requestedFeatures, recoverySessionData, fedAuthFeatureExtensionData, - UserAgent.Ucs2Bytes, + UserAgent.GetUcs2Bytes(SqlClientAgentRegistration.Agent), useFeatureExt, length, true diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent.cs index d8820c3ce9..73dd3896e4 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent.cs @@ -3,8 +3,10 @@ // See the LICENSE file in the project root for more information. using System; +using System.Globalization; using System.Runtime.InteropServices; using System.Text; +using System.Threading; #nullable enable @@ -31,9 +33,10 @@ internal static class UserAgent /// never larger than 256 characters. /// /// - /// The format is pipe ('|') delimited into 7 parts: + /// The format is pipe ('|') delimited into 7 parts, plus an optional + /// 8th part: /// - /// 1|MS-MDS|{Driver Version}|{Arch}|{OS Type}|{OS Info}|{Runtime Info} + /// 2|MS-MDS|{Driver Version}|{Arch}|{OS Type}|{OS Info}|{Runtime Info}[|{Agent Id}] /// /// /// The {Driver Version} part is the version of the driver, @@ -76,6 +79,17 @@ internal static class UserAgent /// Maximum length is 44 characters. /// /// + /// The {Agent Id} part is optional and is appended only when an + /// agent has been registered via + /// + /// RegisterSqlClientAgent + /// . + /// When no agent is registered, the payload ends after + /// {Runtime Info}. See + /// GetUcs2Bytes. + /// Maximum length is 8 characters. + /// + /// /// Any characters from the sourced values that are not one of the /// following are replaced with underscore ('_'): /// @@ -108,6 +122,63 @@ internal static class UserAgent #region Helpers + /// + /// + /// Returns with the registered agent + /// identifier appended as an additional part. + /// + /// + /// When is null, is + /// returned unchanged, with no additional part appended. + /// + /// + /// + /// The registered agent, or null if no agent has been registered. + /// + /// The UCS-2 encoded payload bytes. + internal static ReadOnlyMemory GetUcs2Bytes(SqlClientAgent? agent) + { + if (agent is null) + { + return Ucs2Bytes; + } + + // An agent is registered at most once per process, so a single cached + // entry serves every login. The pair is cached behind one reference so + // readers never observe a torn ReadOnlyMemory. + ushort agentId = (ushort)agent.Value; + AgentPayload? cached = Volatile.Read(ref s_agentPayload); + if (cached is not null && cached.AgentId == agentId) + { + return cached.Ucs2Bytes; + } + + ReadOnlyMemory bytes = Encoding.Unicode.GetBytes(BuildPayload(agentId)); + Volatile.Write(ref s_agentPayload, new AgentPayload(agentId, bytes)); + + return bytes; + } + + /// + /// Build the payload string from the current runtime environment, + /// appending the given agent id when one is supplied. + /// + /// + /// The agent id to append, or null to omit the Agent Id part. + /// + /// The payload string value. + private static string BuildPayload(ushort? agentId) => + Build( + MaxLenOverall, + PayloadVersion, + DriverName, + ThisAssembly.PackageVersion, + RuntimeInformation.ProcessArchitecture, + s_osType, + RuntimeInformation.OSDescription, + RuntimeInformation.FrameworkDescription, + agentId); + /// /// Static construction builds the Client Interface Name. All known /// exceptions are consumed. @@ -139,16 +210,11 @@ static UserAgent() } #endif + // Remember it for agent payloads built later. + s_osType = osType; + // Build it! - Value = Build( - MaxLenOverall, - PayloadVersion, - DriverName, - ThisAssembly.PackageVersion, - RuntimeInformation.ProcessArchitecture, - osType, - RuntimeInformation.OSDescription, - RuntimeInformation.FrameworkDescription); + Value = BuildPayload(agentId: null); // Convert it to UCS-2 bytes. // @@ -189,6 +255,10 @@ static UserAgent() /// /// The value of the Runtime Info part. /// + /// + /// The value of the optional Agent Id part. When null, no Agent Id part + /// is appended. + /// /// /// The payload string value, never null, never empty, and never longer /// than . @@ -201,7 +271,8 @@ internal static string Build( Architecture arch, string osType, string osInfo, - string runtimeInfo) + string runtimeInfo, + ushort? agentId = null) { string result; @@ -245,6 +316,16 @@ internal static string Build( // Add the Runtime Info, truncating to its max length. name.Append(Truncate(Clean(runtimeInfo), MaxLenRuntimeInfo)); + // Add the Agent Id only when an agent has been registered. + if (agentId.HasValue) + { + name.Append('|'); + name.Append( + Truncate( + Clean(agentId.Value.ToString(CultureInfo.InvariantCulture)), + MaxLenAgentId)); + } + // Remember the name we've built up. result = name.ToString(); } @@ -398,7 +479,9 @@ internal static string Truncate(string value, ushort maxLength) #region Private Fields // Our payload format version. - private const string PayloadVersion = "1"; + // + // Version 2 adds the optional Agent Id part. + private const string PayloadVersion = "2"; // Our well-known .NET driver name. private const string DriverName = "MS-MDS"; @@ -414,6 +497,7 @@ internal static string Truncate(string value, ushort maxLength) private const ushort MaxLenOsType = 10; private const ushort MaxLenOsInfo = 44; private const ushort MaxLenRuntimeInfo = 44; + private const ushort MaxLenAgentId = 8; // The OS Type values we promise in our API. private const string Windows = "Windows"; @@ -428,5 +512,28 @@ internal static string Truncate(string value, ushort maxLength) // unknown, invalid, or when errors occur. private const string Unknown = "Unknown"; + // The OS Type resolved during static construction, retained so agent + // payloads are built from the same value as Value. + private static readonly string s_osType; + + // The payload for the registered agent, built on first use. + private static AgentPayload? s_agentPayload; + + /// + /// Pairs a payload with the agent identifier it was built for. + /// + private sealed class AgentPayload + { + internal AgentPayload(ushort agentId, ReadOnlyMemory ucs2Bytes) + { + AgentId = agentId; + Ucs2Bytes = ucs2Bytes; + } + + internal ushort AgentId { get; } + + internal ReadOnlyMemory Ucs2Bytes { get; } + } + #endregion Private Fields } diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs b/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs index c8f18d38bc..9bf5bb2f4d 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs @@ -2814,6 +2814,24 @@ internal static string SQL_ActiveDirectoryInvalidStateTransition { } } + /// + /// Looks up a localized string similar to A SqlClient agent can only be registered once.. + /// + internal static string SQL_SqlClientAgentAlreadyRegistered { + get { + return ResourceManager.GetString("SQL_SqlClientAgentAlreadyRegistered", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The SqlClient agent identifier '{0}' is invalid.. + /// + internal static string SQL_InvalidSqlClientAgent { + get { + return ResourceManager.GetString("SQL_InvalidSqlClientAgent", resourceCulture); + } + } + /// /// Looks up a localized string similar to Unsupported state: '{0}'.. /// diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx b/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx index 57cbf80016..bf1c1b5d2f 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx @@ -2199,6 +2199,12 @@ Cannot transition from state '{0}' to '{1}'. + + A SqlClient agent can only be registered once. + + + The SqlClient agent identifier '{0}' is invalid. + Unsupported state: '{0}'. diff --git a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlClientAgentConfigurationTests.cs b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlClientAgentConfigurationTests.cs new file mode 100644 index 0000000000..938c581e3a --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlClientAgentConfigurationTests.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using Xunit; + +namespace Microsoft.Data.SqlClient.Tests +{ + public class SqlClientAgentConfigurationTests + { + // The FunctionalTests project employs a .NET Framework app.config file + // that registers the EntityFramework agent. Verify that this consumes + // the single process-wide registration, so a later programmatic + // registration is rejected. + // + // This cannot be verified on .NET because the test host substitutes its + // own configuration file for the one built alongside this assembly. + [ConditionalFact(typeof(TestUtility), nameof(TestUtility.IsNetFramework))] + public void AppConfigAgent_PreventsProgrammaticRegistration() + { + Assert.Throws( + () => SqlConnection.RegisterSqlClientAgent(SqlClientAgent.SemanticKernel)); + } + } +} diff --git a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/app.config b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/app.config index 9fc08c65a7..1de2641418 100644 --- a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/app.config +++ b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/app.config @@ -3,7 +3,9 @@
+
+ diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionTests.cs index 1a48ccd293..b48a8821e2 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionTests.cs @@ -27,6 +27,12 @@ namespace Microsoft.Data.SqlClient.UnitTests.SimulatedServerTests { public class ConnectionTests { + private static readonly Lazy s_sqlClientAgentRegistered = new(() => + { + SqlConnection.RegisterSqlClientAgent(SqlClientAgent.EntityFramework); + return true; + }); + [Fact] public void ConnectionTest() { @@ -1170,9 +1176,10 @@ public void TestConnWithVectorFeatExtVersionNegotiation(bool expectedConnectionR } } - // Test that the driver sends the UserAgent feature extension when - // the context switch is enabled, and that the presence or absence of - // an ack from the server has no effect. + /// + /// Verifies that LOGIN7 sends the USERAGENT payload with the globally registered agent + /// identifier appended, regardless of whether the server acknowledges the extension. + /// [Theory] // Allow the server to ack. [InlineData(true)] @@ -1226,18 +1233,20 @@ public void TestConnWithUserAgentFeatureExtension(bool sendAck) Pooling = false, // No pooling needed; avoids leaking a pooled connection to this ephemeral port }.ConnectionString; + _ = s_sqlClientAgentRegistered.Value; using var connection = new SqlConnection(connStr); connection.Open(); // Verify the connection itself succeeded Assert.Equal(ConnectionState.Open, connection.State); + Assert.Throws(() => SqlConnection.RegisterSqlClientAgent(SqlClientAgent.SemanticKernel)); // Verify client did offer UserAgent and captured conditions hold Assert.True(loginFound, "Expected UserAgent extension in LOGIN7"); Assert.True(firstFeatureIsUserAgent); Assert.True(tokenWasNotNull); Assert.True(dataLengthAtLeast1); - Assert.Equal(UserAgent.Ucs2Bytes.ToArray(), observedPayload); + Assert.Equal(UserAgent.GetUcs2Bytes(SqlClientAgent.EntityFramework).ToArray(), observedPayload); // TODO: Confirm the server sent an Ack by reading log message from SqlInternalConnectionTds } diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlClientAgentTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlClientAgentTests.cs new file mode 100644 index 0000000000..729118e9c6 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlClientAgentTests.cs @@ -0,0 +1,112 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Configuration; +using System.IO; +using Xunit; + +namespace Microsoft.Data.SqlClient.UnitTests; + +/// +/// Verifies agent identifier parsing and validation. +/// +public class SqlClientAgentTests +{ + /// Ensures published Microsoft agent identifiers remain stable. + [Fact] + public void KnownAgentIdentifiers_AreStable() + { + Assert.Equal((ushort)1, (ushort)SqlClientAgent.EntityFramework); + Assert.Equal((ushort)2, (ushort)SqlClientAgent.SemanticKernel); + Assert.Equal((ushort)3, (ushort)SqlClientAgent.ManagementStudio); + Assert.Equal((ushort)4, (ushort)SqlClientAgent.SqlManagementObjects); + Assert.Equal((ushort)5, (ushort)SqlClientAgent.DataTierApplicationFramework); + Assert.Equal((ushort)6, (ushort)SqlClientAgent.SqlToolsService); + Assert.Equal((ushort)7, (ushort)SqlClientAgent.AspNetCoreDistributedSqlServerCache); + Assert.Equal((ushort)8, (ushort)SqlClientAgent.EntityFramework6); + Assert.Equal((ushort)9, (ushort)SqlClientAgent.AzureFunctionsSqlExtension); + Assert.Equal((ushort)10, (ushort)SqlClientAgent.OrleansAdoNet); + Assert.Equal((ushort)11, (ushort)SqlClientAgent.DurableTaskSqlServer); + } + + /// Verifies configuration accepts enum names and forward-compatible numeric identifiers. + [Theory] + [InlineData("SqlToolsService", 6)] + [InlineData("42", 42)] + public void Parse_AcceptsNamedAndNumericIdentifiers(string value, ushort expected) + => Assert.Equal(expected, (ushort)SqlClientAgentRegistration.Parse(value)); + + /// Verifies invalid and zero identifiers are rejected. + [Theory] + [InlineData("")] + [InlineData("not-an-agent")] + [InlineData("0")] + // Enum.TryParse would otherwise combine these into an unrelated agent. + [InlineData("EntityFramework,SemanticKernel")] + [InlineData("1,2")] + public void Parse_RejectsInvalidIdentifiers(string value) + => Assert.ThrowsAny(() => SqlClientAgentRegistration.Parse(value)); + + /// + /// Verifies the SqlClientAgent configuration section is declared correctly and yields the + /// expected agent. + /// + /// The host configuration file cannot be exercised here because the test host substitutes its + /// own, so the section is loaded from a mapped configuration file instead. + /// + [Theory] + [InlineData("EntityFramework", 1)] + [InlineData("managementstudio", 3)] + [InlineData("42", 42)] + public void ConfigurationSection_YieldsAgent(string id, ushort expected) + { + SqlClientAgentConfigurationSection section = LoadSection(id); + + Assert.Equal(expected, (ushort)SqlClientAgentRegistration.Parse(section.Id)); + } + + /// + /// Verifies an invalid configured identifier is rejected rather than silently accepted. + /// + [Fact] + public void ConfigurationSection_RejectsInvalidId() + { + SqlClientAgentConfigurationSection section = LoadSection("not-an-agent"); + + Assert.ThrowsAny(() => SqlClientAgentRegistration.Parse(section.Id)); + } + + /// + /// Load the SqlClientAgent section from a temporary configuration file containing the given id. + /// + private static SqlClientAgentConfigurationSection LoadSection(string id) + { + string path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.config"); + File.WriteAllText( + path, + "" + + "" + + "" + + $"
" + + "" + + $"<{SqlClientAgentConfigurationSection.Name} id=\"{id}\" />" + + ""); + + try + { + Configuration configuration = ConfigurationManager.OpenMappedExeConfiguration( + new ExeConfigurationFileMap { ExeConfigFilename = path }, + ConfigurationUserLevel.None); + + return Assert.IsType( + configuration.GetSection(SqlClientAgentConfigurationSection.Name)); + } + finally + { + File.Delete(path); + } + } +} diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentTests.cs index 80b826700e..a79ed50f1c 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentTests.cs @@ -72,11 +72,11 @@ public void Value_Runtime_Parts() // // The format should be: // - // 1|MS-MDS|{Driver Version}|{Arch}|{OS Type}|{OS Info}|{Runtime Info} + // 2|MS-MDS|{Driver Version}|{Arch}|{OS Type}|{OS Info}|{Runtime Info} // var parts = value.Split('|'); Assert.Equal(7, parts.Length); - Assert.Equal("1", parts[0]); + Assert.Equal("2", parts[0]); Assert.Equal("MS-MDS", parts[1]); Assert.Equal(ThisAssembly.PackageVersion, parts[2]); @@ -149,6 +149,82 @@ public void Ucs2Bytes_Runtime_Parts() Assert.Equal(UserAgent.Value, value); } + /// + /// Test that no agent part is appended when no agent is registered. + /// + [Fact] + public void GetUcs2Bytes_No_Agent_Returns_Value() + { + var bytes = UserAgent.GetUcs2Bytes(agent: null); + + Assert.Equal(UserAgent.Ucs2Bytes.ToArray(), bytes.ToArray()); + Assert.Equal(7, Decode(bytes).Split('|').Length); + } + + /// + /// Test that a registered agent is appended as an additional part, leaving + /// the other parts unchanged. + /// + [Fact] + public void GetUcs2Bytes_Agent_Appends_Agent_Id() + { + string value = Decode(UserAgent.GetUcs2Bytes(SqlClientAgent.SemanticKernel)); + + _output.WriteLine($"UserAgent with agent: {value}"); + + Assert.Equal($"{UserAgent.Value}|2", value); + + var parts = value.Split('|'); + Assert.Equal(8, parts.Length); + Assert.Equal("2", parts[7]); + } + + /// + /// Test that the agent payload is built once and reused across logins. + /// + [Fact] + public void GetUcs2Bytes_Agent_Reuses_Payload() + { + Assert.True( + UserAgent.GetUcs2Bytes(SqlClientAgent.ManagementStudio).Span.Overlaps( + UserAgent.GetUcs2Bytes(SqlClientAgent.ManagementStudio).Span)); + } + + /// + /// Test that the Build() function appends the agent id and truncates it to + /// its max length. + /// + [Theory] + [InlineData(null, "2|A|B|X64|C|D|E")] + [InlineData((ushort)0, "2|A|B|X64|C|D|E|0")] + [InlineData((ushort)7, "2|A|B|X64|C|D|E|7")] + [InlineData(ushort.MaxValue, "2|A|B|X64|C|D|E|65535")] + public void Build_Agent_Id(ushort? agentId, string expected) + { + Assert.Equal( + expected, + UserAgent.Build( + maxLen: 256, + payloadVersion: "2", + driverName: "A", + driverVersion: "B", + Architecture.X64, + osType: "C", + osInfo: "D", + runtimeInfo: "E", + agentId: agentId)); + } + + /// + /// Decode a UCS-2 encoded payload back to its string form. + /// + private static string Decode(ReadOnlyMemory bytes) => + #if NET + Encoding.Unicode.GetString(bytes.Span); + #else + Encoding.Unicode.GetString(bytes.ToArray()); + #endif + /// /// Test the Build() function when it truncates the overall length. /// From c9f0cec97ad1a88addcf6041baecdce88ece36a0 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Wed, 2 Sep 2026 12:39:21 -0700 Subject: [PATCH 02/12] Address PR review feedback - Capture the USERAGENT payload once in SendPreLoginHandshake and pass it to WriteLoginData, so a concurrent registration cannot make the reserved feature length disagree with the bytes written. - Restrict RegisterSqlClientAgent to declared enum members. Undeclared numeric ids remain valid in config, where forward compatibility matters. - Serialize ConnectionTests via SimulatedServerTestCollection; it now mutates process-wide registration. - Add XML summary to SqlClientAgentConfigurationTests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: efbed43f-1014-45d7-a9f6-9f04711c281e --- .../Microsoft.Data.SqlClient/SqlConnection.xml | 2 +- .../src/Microsoft/Data/SqlClient/SqlClientAgent.cs | 14 ++++++++++---- .../src/Microsoft/Data/SqlClient/TdsParser.cs | 14 ++++++++++---- .../SqlClientAgentConfigurationTests.cs | 4 ++++ .../SimulatedServerTests/ConnectionTests.cs | 1 + .../tests/UnitTests/SqlClientAgentTests.cs | 8 ++++++++ 6 files changed, 34 insertions(+), 9 deletions(-) diff --git a/doc/snippets/Microsoft.Data.SqlClient/SqlConnection.xml b/doc/snippets/Microsoft.Data.SqlClient/SqlConnection.xml index b311579a97..91cfacd359 100644 --- a/doc/snippets/Microsoft.Data.SqlClient/SqlConnection.xml +++ b/doc/snippets/Microsoft.Data.SqlClient/SqlConnection.xml @@ -2300,7 +2300,7 @@ The following sample tries to open a connection to an invalid database to simula
The positive numeric identifier assigned to the middleware agent. - The numeric value of is zero. + is not a declared value. An agent was already registered programmatically or through application configuration. diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientAgent.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientAgent.cs index 61fe567d14..36cd7f72d6 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientAgent.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientAgent.cs @@ -91,7 +91,7 @@ internal static SqlClientAgent? Agent ///
/// The agent to register. /// - /// is not a valid agent identifier. + /// is not a declared agent identifier. /// /// /// An agent has already been registered. @@ -140,15 +140,21 @@ internal static SqlClientAgent Parse(string value) } /// - /// Throw if the given agent is not a valid agent identifier. + /// Throw if the given agent is not a declared agent identifier. /// + /// + /// The public registration API is a closed enum, so an undeclared value + /// is always a caller mistake. Undeclared numeric identifiers remain + /// valid in , where they let an agent assigned an + /// identifier after this driver shipped still be configured. + /// /// The agent to validate. /// - /// is not a valid agent identifier. + /// is not a declared agent identifier. /// private static void Validate(SqlClientAgent id) { - if ((ushort)id == 0) + if ((ushort)id == 0 || !Enum.IsDefined(typeof(SqlClientAgent), id)) { throw SQL.InvalidSqlClientAgent(id.ToString(), nameof(id)); } diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs index f385462612..bf8a754350 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs @@ -1356,12 +1356,16 @@ internal void TdsLogin( } int feOffset = length; + // Capture the payload once so the length reserved below and the + // bytes written by WriteLoginData can never disagree, even if an + // agent is registered concurrently. + ReadOnlyMemory userAgent = UserAgent.GetUcs2Bytes(SqlClientAgentRegistration.Agent); // calculate and reserve the required bytes for the featureEx length = ApplyFeatureExData( requestedFeatures, recoverySessionData, fedAuthFeatureExtensionData, - UserAgent.GetUcs2Bytes(SqlClientAgentRegistration.Agent), + userAgent, useFeatureExt, length ); @@ -1380,7 +1384,8 @@ internal void TdsLogin( length, feOffset, clientInterfaceName, - sspiWriter is { } ? sspiWriter.WrittenSpan : ReadOnlySpan.Empty); + sspiWriter is { } ? sspiWriter.WrittenSpan : ReadOnlySpan.Empty, + userAgent); } finally { @@ -9261,7 +9266,8 @@ private void WriteLoginData(SqlLogin rec, int length, int featureExOffset, string clientInterfaceName, - ReadOnlySpan outSSPI) + ReadOnlySpan outSSPI, + ReadOnlyMemory userAgent) { try { @@ -9521,7 +9527,7 @@ private void WriteLoginData(SqlLogin rec, requestedFeatures, recoverySessionData, fedAuthFeatureExtensionData, - UserAgent.GetUcs2Bytes(SqlClientAgentRegistration.Agent), + userAgent, useFeatureExt, length, true diff --git a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlClientAgentConfigurationTests.cs b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlClientAgentConfigurationTests.cs index 938c581e3a..4944cdd040 100644 --- a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlClientAgentConfigurationTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlClientAgentConfigurationTests.cs @@ -7,6 +7,10 @@ namespace Microsoft.Data.SqlClient.Tests { + /// + /// Verifies that an agent registered in the application configuration file consumes the single + /// process-wide registration. + /// public class SqlClientAgentConfigurationTests { // The FunctionalTests project employs a .NET Framework app.config file diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionTests.cs index b48a8821e2..63634bca92 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionTests.cs @@ -25,6 +25,7 @@ namespace Microsoft.Data.SqlClient.UnitTests.SimulatedServerTests { + [Collection(SimulatedServerTestCollection.Name)] public class ConnectionTests { private static readonly Lazy s_sqlClientAgentRegistered = new(() => diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlClientAgentTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlClientAgentTests.cs index 729118e9c6..cd44da102b 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlClientAgentTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlClientAgentTests.cs @@ -49,6 +49,14 @@ public void Parse_AcceptsNamedAndNumericIdentifiers(string value, ushort expecte public void Parse_RejectsInvalidIdentifiers(string value) => Assert.ThrowsAny(() => SqlClientAgentRegistration.Parse(value)); + /// Verifies undeclared identifiers are rejected by the public registration API. + [Theory] + [InlineData(0)] + [InlineData(42)] + public void Register_RejectsUndeclaredIdentifiers(ushort id) + => Assert.Throws( + () => SqlConnection.RegisterSqlClientAgent((SqlClientAgent)id)); + /// /// Verifies the SqlClientAgent configuration section is declared correctly and yields the /// expected agent. From 171d890aae46da15da2f01f8cd6e179838fb9c7f Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Wed, 2 Sep 2026 12:50:16 -0700 Subject: [PATCH 03/12] Make SqlClientAgent CLS-compliant and cover config failures - Change SqlClientAgent to int-backed so it needs no CLSCompliant attribute, which the notsupported assembly rejects (CS3021). Identifiers are still bounded to a positive 16-bit range. - Extract LoadAgent so the configuration failure paths are testable, and cover malformed config, invalid id, wrong section type, missing section, and a throwing loader. - Clarify that UserAgent.Value never carries the agent id; only the login payload does. - Convert the App.config test comment to an XML summary. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: efbed43f-1014-45d7-a9f6-9f04711c281e --- .../ref/Microsoft.Data.SqlClient.cs | 4 +- .../Data/SqlClient/SqlClientAgent.cs | 44 +++++-- .../src/Microsoft/Data/SqlClient/UserAgent.cs | 19 +-- .../SqlClientAgentConfigurationTests.cs | 15 +-- .../tests/UnitTests/SqlClientAgentTests.cs | 116 ++++++++++++++---- 5 files changed, 148 insertions(+), 50 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs b/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs index fb2c9aabec..0f8e545545 100644 --- a/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs +++ b/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs @@ -595,8 +595,7 @@ public void LogInfo(string type, string method, string message) { } /// /// Identifies known middleware agents that use Microsoft.Data.SqlClient. /// -[System.CLSCompliantAttribute(false)] -public enum SqlClientAgent : ushort +public enum SqlClientAgent { /// The Microsoft Entity Framework Core SQL Server provider. EntityFramework = 1, @@ -1041,7 +1040,6 @@ public SqlConnection(string connectionString) { } /// public SqlConnection(string connectionString, Microsoft.Data.SqlClient.SqlCredential credential) { } /// - [System.CLSCompliantAttribute(false)] public static void RegisterSqlClientAgent(Microsoft.Data.SqlClient.SqlClientAgent id) { } /// diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientAgent.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientAgent.cs index 36cd7f72d6..c3ea351e05 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientAgent.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientAgent.cs @@ -15,8 +15,7 @@ namespace Microsoft.Data.SqlClient; /// /// Identifies known middleware agents that use Microsoft.Data.SqlClient. /// -[CLSCompliant(false)] -public enum SqlClientAgent : ushort +public enum SqlClientAgent { /// The Microsoft Entity Framework Core SQL Server provider. EntityFramework = 1, @@ -99,7 +98,7 @@ internal static SqlClientAgent? Agent internal static void Register(SqlClientAgent id) { Validate(id); - if (Interlocked.CompareExchange(ref s_agentId, (ushort)id, 0) != 0) + if (Interlocked.CompareExchange(ref s_agentId, (int)id, 0) != 0) { throw SQL.SqlClientAgentAlreadyRegistered(); } @@ -131,7 +130,7 @@ internal static SqlClientAgent Parse(string value) if (value is null || value.IndexOf(',') >= 0 || !Enum.TryParse(value, ignoreCase: true, out SqlClientAgent id) - || (ushort)id == 0) + || !IsInRange(id)) { throw SQL.InvalidSqlClientAgent(value ?? string.Empty, nameof(value)); } @@ -154,12 +153,25 @@ internal static SqlClientAgent Parse(string value) /// private static void Validate(SqlClientAgent id) { - if ((ushort)id == 0 || !Enum.IsDefined(typeof(SqlClientAgent), id)) + if (!IsInRange(id) || !Enum.IsDefined(typeof(SqlClientAgent), id)) { throw SQL.InvalidSqlClientAgent(id.ToString(), nameof(id)); } } + /// + /// Whether the given agent falls within the identifier space reported on + /// the wire. + /// + /// + /// Identifiers are 16-bit and positive. Zero is reserved to mean "no + /// agent registered". + /// + /// The agent to test. + /// True if the agent is in range, false otherwise. + private static bool IsInRange(SqlClientAgent id) => + (int)id > 0 && (int)id <= ushort.MaxValue; + /// /// /// Read the agent registered in the application configuration file. @@ -170,14 +182,30 @@ private static void Validate(SqlClientAgent id) /// The configured agent identifier, or 0 when no valid agent is /// configured. /// - private static int LoadFromAppConfig() + private static int LoadFromAppConfig() => + LoadAgent(() => ConfigurationManager.GetSection(SqlClientAgentConfigurationSection.Name)); + + /// + /// + /// Read the agent from the section returned by the given loader. + /// + /// All known exceptions are consumed. + /// + /// + /// Returns the configuration section, or null when it is absent. + /// + /// + /// The configured agent identifier, or 0 when no valid agent is + /// configured. + /// + internal static int LoadAgent(Func getSection) { // This runs during static initialization on the login path, so any // escaping exception would surface as a TypeInitializationException on // every connection attempt. Telemetry must never break connections. try { - object section = ConfigurationManager.GetSection(SqlClientAgentConfigurationSection.Name); + object? section = getSection(); if (section is null) { return 0; @@ -185,7 +213,7 @@ private static int LoadFromAppConfig() if (section is SqlClientAgentConfigurationSection configurationSection) { - return (ushort)Parse(configurationSection.Id); + return (int)Parse(configurationSection.Id); } SqlClientEventSource.Log.TryTraceEvent( diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent.cs index 73dd3896e4..e8679edaad 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent.cs @@ -33,10 +33,15 @@ internal static class UserAgent /// never larger than 256 characters. /// /// - /// The format is pipe ('|') delimited into 7 parts, plus an optional - /// 8th part: + /// The format is pipe ('|') delimited into 7 parts: /// - /// 2|MS-MDS|{Driver Version}|{Arch}|{OS Type}|{OS Info}|{Runtime Info}[|{Agent Id}] + /// 2|MS-MDS|{Driver Version}|{Arch}|{OS Type}|{OS Info}|{Runtime Info} + /// + /// + /// This is the base value, and never carries an agent identifier. The + /// payload actually sent at login may append an optional 8th + /// {Agent Id} part; see + /// GetUcs2Bytes. /// /// /// The {Driver Version} part is the version of the driver, @@ -79,14 +84,12 @@ internal static class UserAgent /// Maximum length is 44 characters. /// /// - /// The {Agent Id} part is optional and is appended only when an + /// The {Agent Id} part is never present in this value. It is + /// appended by GetUcs2Bytes when an /// agent has been registered via /// /// RegisterSqlClientAgent /// . - /// When no agent is registered, the payload ends after - /// {Runtime Info}. See - /// GetUcs2Bytes. /// Maximum length is 8 characters. /// /// @@ -146,6 +149,8 @@ internal static ReadOnlyMemory GetUcs2Bytes(SqlClientAgent? agent) // An agent is registered at most once per process, so a single cached // entry serves every login. The pair is cached behind one reference so // readers never observe a torn ReadOnlyMemory. + // + // The identifier space is 16-bit, enforced when the agent is registered. ushort agentId = (ushort)agent.Value; AgentPayload? cached = Volatile.Read(ref s_agentPayload); if (cached is not null && cached.AgentId == agentId) diff --git a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlClientAgentConfigurationTests.cs b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlClientAgentConfigurationTests.cs index 4944cdd040..964881e86c 100644 --- a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlClientAgentConfigurationTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlClientAgentConfigurationTests.cs @@ -13,13 +13,14 @@ namespace Microsoft.Data.SqlClient.Tests /// public class SqlClientAgentConfigurationTests { - // The FunctionalTests project employs a .NET Framework app.config file - // that registers the EntityFramework agent. Verify that this consumes - // the single process-wide registration, so a later programmatic - // registration is rejected. - // - // This cannot be verified on .NET because the test host substitutes its - // own configuration file for the one built alongside this assembly. + /// + /// Verifies that an agent registered in the application configuration file consumes the + /// single process-wide registration, so a later programmatic registration is rejected. + /// + /// + /// This cannot be verified on .NET because the test host substitutes its own configuration + /// file for the one built alongside this assembly. + /// [ConditionalFact(typeof(TestUtility), nameof(TestUtility.IsNetFramework))] public void AppConfigAgent_PreventsProgrammaticRegistration() { diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlClientAgentTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlClientAgentTests.cs index cd44da102b..5c2f200be1 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlClientAgentTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlClientAgentTests.cs @@ -18,25 +18,25 @@ public class SqlClientAgentTests [Fact] public void KnownAgentIdentifiers_AreStable() { - Assert.Equal((ushort)1, (ushort)SqlClientAgent.EntityFramework); - Assert.Equal((ushort)2, (ushort)SqlClientAgent.SemanticKernel); - Assert.Equal((ushort)3, (ushort)SqlClientAgent.ManagementStudio); - Assert.Equal((ushort)4, (ushort)SqlClientAgent.SqlManagementObjects); - Assert.Equal((ushort)5, (ushort)SqlClientAgent.DataTierApplicationFramework); - Assert.Equal((ushort)6, (ushort)SqlClientAgent.SqlToolsService); - Assert.Equal((ushort)7, (ushort)SqlClientAgent.AspNetCoreDistributedSqlServerCache); - Assert.Equal((ushort)8, (ushort)SqlClientAgent.EntityFramework6); - Assert.Equal((ushort)9, (ushort)SqlClientAgent.AzureFunctionsSqlExtension); - Assert.Equal((ushort)10, (ushort)SqlClientAgent.OrleansAdoNet); - Assert.Equal((ushort)11, (ushort)SqlClientAgent.DurableTaskSqlServer); + Assert.Equal(1, (int)SqlClientAgent.EntityFramework); + Assert.Equal(2, (int)SqlClientAgent.SemanticKernel); + Assert.Equal(3, (int)SqlClientAgent.ManagementStudio); + Assert.Equal(4, (int)SqlClientAgent.SqlManagementObjects); + Assert.Equal(5, (int)SqlClientAgent.DataTierApplicationFramework); + Assert.Equal(6, (int)SqlClientAgent.SqlToolsService); + Assert.Equal(7, (int)SqlClientAgent.AspNetCoreDistributedSqlServerCache); + Assert.Equal(8, (int)SqlClientAgent.EntityFramework6); + Assert.Equal(9, (int)SqlClientAgent.AzureFunctionsSqlExtension); + Assert.Equal(10, (int)SqlClientAgent.OrleansAdoNet); + Assert.Equal(11, (int)SqlClientAgent.DurableTaskSqlServer); } /// Verifies configuration accepts enum names and forward-compatible numeric identifiers. [Theory] [InlineData("SqlToolsService", 6)] [InlineData("42", 42)] - public void Parse_AcceptsNamedAndNumericIdentifiers(string value, ushort expected) - => Assert.Equal(expected, (ushort)SqlClientAgentRegistration.Parse(value)); + public void Parse_AcceptsNamedAndNumericIdentifiers(string value, int expected) + => Assert.Equal(expected, (int)SqlClientAgentRegistration.Parse(value)); /// Verifies invalid and zero identifiers are rejected. [Theory] @@ -46,6 +46,9 @@ public void Parse_AcceptsNamedAndNumericIdentifiers(string value, ushort expecte // Enum.TryParse would otherwise combine these into an unrelated agent. [InlineData("EntityFramework,SemanticKernel")] [InlineData("1,2")] + // Identifiers are 16-bit and positive. + [InlineData("-1")] + [InlineData("70000")] public void Parse_RejectsInvalidIdentifiers(string value) => Assert.ThrowsAny(() => SqlClientAgentRegistration.Parse(value)); @@ -53,7 +56,8 @@ public void Parse_RejectsInvalidIdentifiers(string value) [Theory] [InlineData(0)] [InlineData(42)] - public void Register_RejectsUndeclaredIdentifiers(ushort id) + [InlineData(-1)] + public void Register_RejectsUndeclaredIdentifiers(int id) => Assert.Throws( () => SqlConnection.RegisterSqlClientAgent((SqlClientAgent)id)); @@ -68,11 +72,11 @@ public void Register_RejectsUndeclaredIdentifiers(ushort id) [InlineData("EntityFramework", 1)] [InlineData("managementstudio", 3)] [InlineData("42", 42)] - public void ConfigurationSection_YieldsAgent(string id, ushort expected) + public void ConfigurationSection_YieldsAgent(string id, int expected) { SqlClientAgentConfigurationSection section = LoadSection(id); - Assert.Equal(expected, (ushort)SqlClientAgentRegistration.Parse(section.Id)); + Assert.Equal(expected, (int)SqlClientAgentRegistration.Parse(section.Id)); } /// @@ -86,14 +90,59 @@ public void ConfigurationSection_RejectsInvalidId() Assert.ThrowsAny(() => SqlClientAgentRegistration.Parse(section.Id)); } + /// + /// Verifies a malformed configuration file is consumed rather than escaping as a + /// TypeInitializationException on the login path. + /// + [Fact] + public void LoadAgent_MalformedConfigurationFile_YieldsNoAgent() + { + string path = WriteConfig("Verifies a valid configured identifier is loaded. + [Fact] + public void LoadAgent_ValidId_YieldsAgent() + => Assert.Equal(6, SqlClientAgentRegistration.LoadAgent(() => LoadSection("SqlToolsService"))); + + /// Verifies an absent section yields no agent. + [Fact] + public void LoadAgent_NoSection_YieldsNoAgent() + => Assert.Equal(0, SqlClientAgentRegistration.LoadAgent(() => null)); + + /// Verifies a section of an unexpected type is consumed and yields no agent. + [Fact] + public void LoadAgent_UnexpectedSectionType_YieldsNoAgent() + => Assert.Equal(0, SqlClientAgentRegistration.LoadAgent(() => "not-a-section")); + + /// Verifies a throwing section loader is consumed and yields no agent. + [Fact] + public void LoadAgent_ThrowingLoader_YieldsNoAgent() + => Assert.Equal( + 0, + SqlClientAgentRegistration.LoadAgent( + () => throw new ConfigurationErrorsException("bad configuration"))); + /// /// Load the SqlClientAgent section from a temporary configuration file containing the given id. /// private static SqlClientAgentConfigurationSection LoadSection(string id) { - string path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.config"); - File.WriteAllText( - path, + string path = WriteConfig( "" + "" + "" + @@ -105,16 +154,33 @@ private static SqlClientAgentConfigurationSection LoadSection(string id) try { - Configuration configuration = ConfigurationManager.OpenMappedExeConfiguration( - new ExeConfigurationFileMap { ExeConfigFilename = path }, - ConfigurationUserLevel.None); - - return Assert.IsType( - configuration.GetSection(SqlClientAgentConfigurationSection.Name)); + return Assert.IsType(OpenSection(path)); } finally { File.Delete(path); } } + + /// + /// Write the given content to a temporary configuration file and return its path. + /// + private static string WriteConfig(string content) + { + string path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.config"); + File.WriteAllText(path, content); + return path; + } + + /// + /// Read the SqlClientAgent section from the configuration file at the given path. + /// + /// The host configuration file cannot be exercised here because the test host substitutes its + /// own, so a mapped configuration file is used instead. + /// + private static object OpenSection(string path) + => ConfigurationManager.OpenMappedExeConfiguration( + new ExeConfigurationFileMap { ExeConfigFilename = path }, + ConfigurationUserLevel.None) + .GetSection(SqlClientAgentConfigurationSection.Name); } From 1d296c953eb3579ad3d3505ad13d69948776418c Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Wed, 2 Sep 2026 12:56:47 -0700 Subject: [PATCH 04/12] Lock SqlClientAgent underlying type and drop stale attribute - Remove the now-unnecessary CLSCompliant attribute from the implementation method. - Document the 16-bit identifier contract on the enum, since it is no longer implied by the underlying type. - Assert the underlying type is Int32 so the CLS-compliant surface cannot regress. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: efbed43f-1014-45d7-a9f6-9f04711c281e --- .../ref/Microsoft.Data.SqlClient.cs | 5 +++++ .../src/Microsoft/Data/SqlClient/SqlClientAgent.cs | 5 +++++ .../src/Microsoft/Data/SqlClient/SqlConnection.cs | 1 - .../tests/UnitTests/SqlClientAgentTests.cs | 7 +++++++ 4 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs b/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs index 0f8e545545..0ce774049b 100644 --- a/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs +++ b/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs @@ -595,6 +595,11 @@ public void LogInfo(string type, string method, string message) { } /// /// Identifies known middleware agents that use Microsoft.Data.SqlClient. /// +/// +/// Identifiers are positive and fit in 16 bits. The underlying type is +/// so the enum stays CLS-compliant; the range is enforced +/// when an agent is registered. +/// public enum SqlClientAgent { /// The Microsoft Entity Framework Core SQL Server provider. diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientAgent.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientAgent.cs index c3ea351e05..5ebcf82283 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientAgent.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientAgent.cs @@ -15,6 +15,11 @@ namespace Microsoft.Data.SqlClient; /// /// Identifies known middleware agents that use Microsoft.Data.SqlClient. /// +/// +/// Identifiers are positive and fit in 16 bits. The underlying type is +/// so the enum stays CLS-compliant; the range is enforced +/// when an agent is registered. +/// public enum SqlClientAgent { /// The Microsoft Entity Framework Core SQL Server provider. diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs index 7ebd663e03..8da1c75dd5 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs @@ -382,7 +382,6 @@ public static void RegisterColumnEncryptionKeyStoreProviders(IDictionary - [CLSCompliant(false)] public static void RegisterSqlClientAgent(SqlClientAgent id) => SqlClientAgentRegistration.Register(id); diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlClientAgentTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlClientAgentTests.cs index 5c2f200be1..f8b0de87fe 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlClientAgentTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlClientAgentTests.cs @@ -14,6 +14,13 @@ namespace Microsoft.Data.SqlClient.UnitTests; /// public class SqlClientAgentTests { + /// + /// Ensures the published underlying type stays Int32, which keeps the enum CLS-compliant. + /// + [Fact] + public void UnderlyingType_IsInt32() + => Assert.Equal(typeof(int), Enum.GetUnderlyingType(typeof(SqlClientAgent))); + /// Ensures published Microsoft agent identifiers remain stable. [Fact] public void KnownAgentIdentifiers_AreStable() From 0c2bfcc40f90279a273b12ce758d700bbd37b733 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Wed, 2 Sep 2026 13:12:19 -0700 Subject: [PATCH 05/12] Inline the agent registration sample doc/samples builds against the published Microsoft.Data.SqlClient package, so it cannot reference an API that has not shipped yet. Move the example into the XML docs alongside the existing App.config example and drop the compiled sample file. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: efbed43f-1014-45d7-a9f6-9f04711c281e --- .../SqlConnection_RegisterSqlClientAgent.cs | 13 ------------- .../Microsoft.Data.SqlClient/SqlConnection.xml | 15 ++++++++++++++- 2 files changed, 14 insertions(+), 14 deletions(-) delete mode 100644 doc/samples/SqlConnection_RegisterSqlClientAgent.cs diff --git a/doc/samples/SqlConnection_RegisterSqlClientAgent.cs b/doc/samples/SqlConnection_RegisterSqlClientAgent.cs deleted file mode 100644 index 3d800ed1d8..0000000000 --- a/doc/samples/SqlConnection_RegisterSqlClientAgent.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.Data.SqlClient; - -internal static class MiddlewareRegistration -{ - // Register once during application startup, before opening any connections. - internal static void Register() - { - SqlConnection.RegisterSqlClientAgent(SqlClientAgent.EntityFramework); - } -} diff --git a/doc/snippets/Microsoft.Data.SqlClient/SqlConnection.xml b/doc/snippets/Microsoft.Data.SqlClient/SqlConnection.xml index 91cfacd359..9611595a50 100644 --- a/doc/snippets/Microsoft.Data.SqlClient/SqlConnection.xml +++ b/doc/snippets/Microsoft.Data.SqlClient/SqlConnection.xml @@ -2306,7 +2306,20 @@ The following sample tries to open a connection to an invalid database to simula An agent was already registered programmatically or through application configuration. - [!code-csharp[Register an agent](~/../sqlclient/doc/samples/SqlConnection_RegisterSqlClientAgent.cs)] + + Register the agent once during application startup: + + + using Microsoft.Data.SqlClient; + + internal static class MiddlewareRegistration + { + internal static void Register() + { + SqlConnection.RegisterSqlClientAgent(SqlClientAgent.EntityFramework); + } + } + From a1ae554edbe7afe87b6aa29b7a6d25e1e46115d2 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Wed, 2 Sep 2026 14:59:50 -0700 Subject: [PATCH 06/12] Tests | Stop CancelAndDisposePreparedCommand deadlocking on the catalog CancelAndDisposePreparedCommand runs a 6-way cross join over sys.objects purely to produce a large result set. The scan takes shared locks on the catalog, so a concurrent DDL from another test leg on the shared database can pick it as the deadlock victim. Read the catalog with NOLOCK. The statement stays a single prepared SELECT and returns the same rows, so the test still covers what it was written for (disposing a connection whose prepared command was cancelled mid-read). Pre-existing flake, not related to the USERAGENT change in this PR; it also fails the same way on #4630. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: efbed43f-1014-45d7-a9f6-9f04711c281e --- .../tests/ManualTests/SQL/SqlCommand/SqlCommandCancelTest.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/SqlCommand/SqlCommandCancelTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/SqlCommand/SqlCommandCancelTest.cs index 830f1bb733..472e3f57dd 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/SqlCommand/SqlCommandCancelTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/SqlCommand/SqlCommandCancelTest.cs @@ -329,7 +329,9 @@ private static void CancelAndDisposePreparedCommand(string constr) try { // Generate a query with a large number of results. - using (var command = new SqlCommand("select @P from sys.objects a cross join sys.objects b cross join sys.objects c cross join sys.objects d cross join sys.objects e cross join sys.objects f", connection)) + // NOLOCK keeps the catalog scan from taking shared locks, so a + // concurrent DDL on the shared test database cannot deadlock it. + using (var command = new SqlCommand("select @P from sys.objects a with (nolock) cross join sys.objects b with (nolock) cross join sys.objects c with (nolock) cross join sys.objects d with (nolock) cross join sys.objects e with (nolock) cross join sys.objects f with (nolock)", connection)) { command.Parameters.Add(new SqlParameter("@P", SqlDbType.Int) { Value = expectedValue }); connection.Open(); From 9c7f4b0ff00e47a00f1ef7ad0790a09b62aa7ec0 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Wed, 2 Sep 2026 17:51:00 -0700 Subject: [PATCH 07/12] Report agent registration outcome and stop using the catalog in cancel test RegisterSqlClientAgent now returns bool instead of throwing when an agent is already registered. An application can load several middleware libraries that each register themselves, and only the first can win; making the loser throw meant every middleware had to guard the call or risk faulting the host. The first registration still wins and still cannot be replaced. Invalid identifiers continue to throw ArgumentOutOfRangeException, since that is a programming error rather than a lost race. CancelAndDisposePreparedCommand now builds its large result set from constant row sets. NOLOCK still takes schema-stability locks and can additionally fail a scan with error 601 on concurrent catalog changes, so it did not decouple the test from DDL on the shared database. Constant row sets touch no catalog at all, and 16^6 rows keep enough data on the wire to cancel mid-read. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: efbed43f-1014-45d7-a9f6-9f04711c281e --- .../SqlConnection.xml | 11 +++++--- .../ref/Microsoft.Data.SqlClient.cs | 2 +- .../Data/SqlClient/SqlClientAgent.cs | 21 ++++++++++------ .../Microsoft/Data/SqlClient/SqlConnection.cs | 2 +- .../src/Microsoft/Data/SqlClient/SqlUtil.cs | 5 ---- .../src/Resources/Strings.Designer.cs | 9 ------- .../src/Resources/Strings.resx | 3 --- .../SqlClientAgentConfigurationTests.cs | 3 +-- .../SQL/SqlCommand/SqlCommandCancelTest.cs | 10 +++++--- .../SimulatedServerTests/ConnectionTests.cs | 8 +++--- .../tests/UnitTests/SqlClientAgentTests.cs | 25 +++++++++++++++++++ 11 files changed, 59 insertions(+), 40 deletions(-) diff --git a/doc/snippets/Microsoft.Data.SqlClient/SqlConnection.xml b/doc/snippets/Microsoft.Data.SqlClient/SqlConnection.xml index 9611595a50..61c4885711 100644 --- a/doc/snippets/Microsoft.Data.SqlClient/SqlConnection.xml +++ b/doc/snippets/Microsoft.Data.SqlClient/SqlConnection.xml @@ -2299,12 +2299,13 @@ The following sample tries to open a connection to an invalid database to simula Globally registers the middleware agent for physical connections opened by Microsoft.Data.SqlClient. The positive numeric identifier assigned to the middleware agent. + + if this call registered ; otherwise , + meaning an agent was already registered and that registration is left unchanged. + is not a declared value. - - An agent was already registered programmatically or through application configuration. - Register the agent once during application startup: @@ -2329,6 +2330,10 @@ The following sample tries to open a connection to an invalid database to simula Register the agent once during application startup, before opening any connections. The first registration applies process-wide and cannot be replaced. Existing physical connections are not updated after registration. + + Losing the race is not an error. An application may load several middleware libraries that each register + themselves, and only the first can win, so this method reports the outcome instead of throwing. + An agent can instead be registered from an application configuration file. Configuration is loaded before programmatic registration and therefore takes precedence: diff --git a/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs b/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs index 0ce774049b..6be2765466 100644 --- a/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs +++ b/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs @@ -1045,7 +1045,7 @@ public SqlConnection(string connectionString) { } /// public SqlConnection(string connectionString, Microsoft.Data.SqlClient.SqlCredential credential) { } /// - public static void RegisterSqlClientAgent(Microsoft.Data.SqlClient.SqlClientAgent id) { } + public static bool RegisterSqlClientAgent(Microsoft.Data.SqlClient.SqlClientAgent id) { throw null; } /// [System.ComponentModel.BrowsableAttribute(false)] diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientAgent.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientAgent.cs index 5ebcf82283..8a535e19d5 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientAgent.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientAgent.cs @@ -94,19 +94,24 @@ internal static SqlClientAgent? Agent /// Register the given agent for the lifetime of the process. /// /// The agent to register. + /// + /// if this call registered ; + /// otherwise , meaning an agent was already + /// registered and that registration is left unchanged. + /// + /// + /// Losing the race is not an error. An application may load several + /// middleware libraries that each register themselves, and only the first + /// can win; reporting that through the return value keeps the others from + /// having to guard the call. + /// /// /// is not a declared agent identifier. /// - /// - /// An agent has already been registered. - /// - internal static void Register(SqlClientAgent id) + internal static bool Register(SqlClientAgent id) { Validate(id); - if (Interlocked.CompareExchange(ref s_agentId, (int)id, 0) != 0) - { - throw SQL.SqlClientAgentAlreadyRegistered(); - } + return Interlocked.CompareExchange(ref s_agentId, (int)id, 0) == 0; } /// diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs index 8da1c75dd5..63dd6a3eef 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs @@ -382,7 +382,7 @@ public static void RegisterColumnEncryptionKeyStoreProviders(IDictionary - public static void RegisterSqlClientAgent(SqlClientAgent id) + public static bool RegisterSqlClientAgent(SqlClientAgent id) => SqlClientAgentRegistration.Register(id); /// diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlUtil.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlUtil.cs index 613dce226f..bf5d20fb7c 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlUtil.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlUtil.cs @@ -1856,11 +1856,6 @@ internal static Exception EmptyProviderName() #endregion Always Encrypted Errors - internal static Exception SqlClientAgentAlreadyRegistered() - { - return ADP.InvalidOperation(StringsHelper.GetString(Strings.SQL_SqlClientAgentAlreadyRegistered)); - } - internal static Exception InvalidSqlClientAgent(string value, string parameterName) { return ADP.ArgumentOutOfRange(StringsHelper.GetString(Strings.SQL_InvalidSqlClientAgent, value), parameterName); diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs b/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs index 9bf5bb2f4d..80fe5a6084 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs @@ -2814,15 +2814,6 @@ internal static string SQL_ActiveDirectoryInvalidStateTransition { } } - /// - /// Looks up a localized string similar to A SqlClient agent can only be registered once.. - /// - internal static string SQL_SqlClientAgentAlreadyRegistered { - get { - return ResourceManager.GetString("SQL_SqlClientAgentAlreadyRegistered", resourceCulture); - } - } - /// /// Looks up a localized string similar to The SqlClient agent identifier '{0}' is invalid.. /// diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx b/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx index bf1c1b5d2f..f52f0515b5 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx @@ -2199,9 +2199,6 @@ Cannot transition from state '{0}' to '{1}'. - - A SqlClient agent can only be registered once. - The SqlClient agent identifier '{0}' is invalid. diff --git a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlClientAgentConfigurationTests.cs b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlClientAgentConfigurationTests.cs index 964881e86c..30f10cb603 100644 --- a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlClientAgentConfigurationTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlClientAgentConfigurationTests.cs @@ -24,8 +24,7 @@ public class SqlClientAgentConfigurationTests [ConditionalFact(typeof(TestUtility), nameof(TestUtility.IsNetFramework))] public void AppConfigAgent_PreventsProgrammaticRegistration() { - Assert.Throws( - () => SqlConnection.RegisterSqlClientAgent(SqlClientAgent.SemanticKernel)); + Assert.False(SqlConnection.RegisterSqlClientAgent(SqlClientAgent.SemanticKernel)); } } } diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/SqlCommand/SqlCommandCancelTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/SqlCommand/SqlCommandCancelTest.cs index 472e3f57dd..7be39757a2 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/SqlCommand/SqlCommandCancelTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/SqlCommand/SqlCommandCancelTest.cs @@ -329,9 +329,13 @@ private static void CancelAndDisposePreparedCommand(string constr) try { // Generate a query with a large number of results. - // NOLOCK keeps the catalog scan from taking shared locks, so a - // concurrent DDL on the shared test database cannot deadlock it. - using (var command = new SqlCommand("select @P from sys.objects a with (nolock) cross join sys.objects b with (nolock) cross join sys.objects c with (nolock) cross join sys.objects d with (nolock) cross join sys.objects e with (nolock) cross join sys.objects f with (nolock)", connection)) + // The rows come from constant row sets rather than the system + // catalog, so the scan cannot contend with concurrent DDL on + // the shared test database. + const string rows = "(values(0),(1),(2),(3),(4),(5),(6),(7),(8),(9),(10),(11),(12),(13),(14),(15))"; + string sql = $"select @P from {rows} a(n) cross join {rows} b(n) cross join {rows} c(n) " + + $"cross join {rows} d(n) cross join {rows} e(n) cross join {rows} f(n)"; + using (var command = new SqlCommand(sql, connection)) { command.Parameters.Add(new SqlParameter("@P", SqlDbType.Int) { Value = expectedValue }); connection.Open(); diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionTests.cs index 63634bca92..f4e8e03f11 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionTests.cs @@ -29,10 +29,7 @@ namespace Microsoft.Data.SqlClient.UnitTests.SimulatedServerTests public class ConnectionTests { private static readonly Lazy s_sqlClientAgentRegistered = new(() => - { - SqlConnection.RegisterSqlClientAgent(SqlClientAgent.EntityFramework); - return true; - }); + SqlConnection.RegisterSqlClientAgent(SqlClientAgent.EntityFramework)); [Fact] public void ConnectionTest() @@ -1240,7 +1237,8 @@ public void TestConnWithUserAgentFeatureExtension(bool sendAck) // Verify the connection itself succeeded Assert.Equal(ConnectionState.Open, connection.State); - Assert.Throws(() => SqlConnection.RegisterSqlClientAgent(SqlClientAgent.SemanticKernel)); + // A second registration loses to the first and leaves it in place. + Assert.False(SqlConnection.RegisterSqlClientAgent(SqlClientAgent.SemanticKernel)); // Verify client did offer UserAgent and captured conditions hold Assert.True(loginFound, "Expected UserAgent extension in LOGIN7"); diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlClientAgentTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlClientAgentTests.cs index f8b0de87fe..dd341f5319 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlClientAgentTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlClientAgentTests.cs @@ -68,6 +68,31 @@ public void Register_RejectsUndeclaredIdentifiers(int id) => Assert.Throws( () => SqlConnection.RegisterSqlClientAgent((SqlClientAgent)id)); + /// + /// Verifies registration reports whether it won, so a second middleware that registers after + /// the first does not fault the application. + /// + [Fact] + public void Register_ReportsWhetherItWon() + { + // The first caller to register in this process wins; every later caller loses and leaves + // the winning registration in place. Which of the two happens here depends on whether + // another test in this assembly already registered, so accept either and assert that the + // registration is single-valued afterwards. + bool won = SqlConnection.RegisterSqlClientAgent(SqlClientAgent.EntityFramework); + SqlClientAgent? registered = SqlClientAgentRegistration.Agent; + + Assert.NotNull(registered); + if (won) + { + Assert.Equal(SqlClientAgent.EntityFramework, registered); + } + + // Whoever won, a subsequent registration always loses and cannot replace the agent. + Assert.False(SqlConnection.RegisterSqlClientAgent(SqlClientAgent.SemanticKernel)); + Assert.Equal(registered, SqlClientAgentRegistration.Agent); + } + /// /// Verifies the SqlClientAgent configuration section is declared correctly and yields the /// expected agent. From 2f82e5e866c119fbad607aadd155f3cead8dda22 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Thu, 3 Sep 2026 14:53:21 -0700 Subject: [PATCH 08/12] Report application identity in USERAGENT payload V2 Replace the process-wide agent registration with a per-connection application identity, matching the updated USERAGENT V2 spec. The payload now carries nine parts. The App Id and the driver-owned Driver Properties parts are always present, each written as four uppercase hexadecimal characters: 2|MS-MDS|6.1.3|X64|Windows|...|.NET 9.0.4|0000|0001 - SqlConnection.RegisterSqlClientAgent is replaced by the SqlConnection.SqlClientAppId property, set before the connection is opened. Values outside the 16-bit range are rejected rather than silently truncated at login. - SqlClientAgent is renamed to SqlClientApp. The enum keeps the default int backing so it stays CLS-compliant, and unregistered identifiers can still be reported by casting. - SqlClientDriverProperties tracks driver-owned feature flags, starting with connection pool V2 enablement. - App.config registration is removed. It only had meaning while registration was process-wide and once-only. Public type documentation lives in doc/snippets, per repo convention. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: efbed43f-1014-45d7-a9f6-9f04711c281e --- .../Microsoft.Data.SqlClient/SqlClientApp.xml | 129 +++++++++ .../SqlConnection.xml | 63 ++-- .../ref/Microsoft.Data.SqlClient.cs | 61 ++-- .../Connection/SqlConnectionInternal.cs | 11 +- .../Data/SqlClient/SqlClientAgent.cs | 274 ------------------ .../Microsoft/Data/SqlClient/SqlClientApp.cs | 36 +++ .../SqlClient/SqlClientDriverProperties.cs | 61 ++++ .../Microsoft/Data/SqlClient/SqlConnection.cs | 21 +- .../Data/SqlClient/SqlConnectionFactory.cs | 3 +- .../src/Microsoft/Data/SqlClient/SqlUtil.cs | 8 +- .../src/Microsoft/Data/SqlClient/TdsParser.cs | 2 +- .../Data/SqlClient/TdsParserHelperClasses.cs | 1 + .../src/Microsoft/Data/SqlClient/UserAgent.cs | 165 +++++++---- .../src/Resources/Strings.Designer.cs | 8 +- .../src/Resources/Strings.resx | 4 +- .../SqlClientAgentConfigurationTests.cs | 30 -- .../tests/FunctionalTests/app.config | 2 - .../SimulatedServerTests/ConnectionTests.cs | 13 +- .../tests/UnitTests/SqlClientAgentTests.cs | 218 -------------- .../tests/UnitTests/SqlClientAppTests.cs | 125 ++++++++ .../tests/UnitTests/UserAgentTests.cs | 104 ++++--- 21 files changed, 617 insertions(+), 722 deletions(-) create mode 100644 doc/snippets/Microsoft.Data.SqlClient/SqlClientApp.xml delete mode 100644 src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientAgent.cs create mode 100644 src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientApp.cs create mode 100644 src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientDriverProperties.cs delete mode 100644 src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlClientAgentConfigurationTests.cs delete mode 100644 src/Microsoft.Data.SqlClient/tests/UnitTests/SqlClientAgentTests.cs create mode 100644 src/Microsoft.Data.SqlClient/tests/UnitTests/SqlClientAppTests.cs diff --git a/doc/snippets/Microsoft.Data.SqlClient/SqlClientApp.xml b/doc/snippets/Microsoft.Data.SqlClient/SqlClientApp.xml new file mode 100644 index 0000000000..b9f9746d9b --- /dev/null +++ b/doc/snippets/Microsoft.Data.SqlClient/SqlClientApp.xml @@ -0,0 +1,129 @@ + + + + + Specifies the known application identifiers that Microsoft.Data.SqlClient reports for user agent telemetry. + + + + Production applications that meet the bar are welcome to reserve an identifier here. + + + Identifier reservations are as follows: + + + + 0x0001-0x7FFF: Microsoft-defined large-scale applications. + + + 0x8000-0xBFFF: Reserved for small-scale use. + + + 0xC000-0xFFFF: Public and developer use. + + + + An unregistered identifier may still be reported by casting a value to this type. Identifiers are limited to + the 16-bit space the protocol allows, so a value outside 0x0000 to 0xFFFF is rejected when it is assigned to + . + + + + + + No application identity is reported. This is the default. + + + 0 + + + + + The Microsoft Entity Framework Core SQL Server provider. + + + 1 + + + + + Microsoft Semantic Kernel. + + + 2 + + + + + Microsoft SQL Server Management Studio. + + + 3 + + + + + Microsoft SQL Server Management Objects. + + + 4 + + + + + Microsoft SQL Server Data-Tier Application Framework. + + + 5 + + + + + Microsoft SQL Tools Service. + + + 6 + + + + + Microsoft ASP.NET Core distributed SQL Server cache. + + + 7 + + + + + Microsoft Entity Framework 6 SQL Server provider. + + + 8 + + + + + Microsoft Azure Functions SQL extension. + + + 9 + + + + + Microsoft Orleans ADO.NET providers. + + + 10 + + + + + Microsoft Durable Task SQL Server provider. + + + 11 + + + + diff --git a/doc/snippets/Microsoft.Data.SqlClient/SqlConnection.xml b/doc/snippets/Microsoft.Data.SqlClient/SqlConnection.xml index 61c4885711..5b24f08eac 100644 --- a/doc/snippets/Microsoft.Data.SqlClient/SqlConnection.xml +++ b/doc/snippets/Microsoft.Data.SqlClient/SqlConnection.xml @@ -2294,65 +2294,42 @@ The following sample tries to open a connection to an invalid database to simula - + - Globally registers the middleware agent for physical connections opened by Microsoft.Data.SqlClient. + Gets or sets the middleware application identity reported to the server for this connection. - The positive numeric identifier assigned to the middleware agent. - - if this call registered ; otherwise , - meaning an agent was already registered and that registration is left unchanged. - - - is not a declared value. - + + A value. The default is + . + - Register the agent once during application startup: + Set the identity before opening the connection: using Microsoft.Data.SqlClient; - internal static class MiddlewareRegistration - { - internal static void Register() - { - SqlConnection.RegisterSqlClientAgent(SqlClientAgent.EntityFramework); - } - } + using var connection = new SqlConnection(connectionString); + connection.SqlClientAppId = SqlClientApp.EntityFramework; + connection.Open(); + + The value is outside the range 0 to 65535. + - This API is intended only for approved middleware partners. Applications should not call it directly. + This API is intended for registered applications that reserve an identifier in + . An unregistered identifier may be reported by casting + a value to that type, provided it is within the 16-bit range the protocol allows. - Register the agent once during application startup, before opening any connections. The first registration - applies process-wide and cannot be replaced. Existing physical connections are not updated after registration. - - - Losing the race is not an error. An application may load several middleware libraries that each register - themselves, and only the first can win, so this method reports the outcome instead of throwing. - - - An agent can instead be registered from an application configuration file. Configuration is loaded before - programmatic registration and therefore takes precedence: - - - <configuration> - <configSections> - <section name="SqlClientAgent" - type="Microsoft.Data.SqlClient.SqlClientAgentConfigurationSection,Microsoft.Data.SqlClient" /> - </configSections> - <SqlClientAgent id="EntityFramework" /> - </configuration> - - - The id value can be an enum member name or a positive numeric value. Numeric values not declared by - are accepted for forward compatibility. + The identity is sent once, during login, so it must be set before the connection is opened. When pooling is + enabled, the value is reported only when a new physical connection is established; a connection served from + the pool reports the identity of the connection that created it. - + Gets a string that identifies the database client. diff --git a/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs b/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs index 6be2765466..8b9e1fa689 100644 --- a/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs +++ b/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs @@ -592,38 +592,33 @@ public void LogError(string type, string method, string message) { } public void LogInfo(string type, string method, string message) { } } -/// -/// Identifies known middleware agents that use Microsoft.Data.SqlClient. -/// -/// -/// Identifiers are positive and fit in 16 bits. The underlying type is -/// so the enum stays CLS-compliant; the range is enforced -/// when an agent is registered. -/// -public enum SqlClientAgent +/// +public enum SqlClientApp { - /// The Microsoft Entity Framework Core SQL Server provider. - EntityFramework = 1, - /// Microsoft Semantic Kernel. - SemanticKernel = 2, - /// Microsoft SQL Server Management Studio. - ManagementStudio = 3, - /// Microsoft SQL Server Management Objects. - SqlManagementObjects = 4, - /// Microsoft SQL Server Data-Tier Application Framework. - DataTierApplicationFramework = 5, - /// Microsoft SQL Tools Service. - SqlToolsService = 6, - /// Microsoft ASP.NET Core distributed SQL Server cache. - AspNetCoreDistributedSqlServerCache = 7, - /// Microsoft Entity Framework 6 SQL Server provider. - EntityFramework6 = 8, - /// Microsoft Azure Functions SQL extension. - AzureFunctionsSqlExtension = 9, - /// Microsoft Orleans ADO.NET providers. - OrleansAdoNet = 10, - /// Microsoft Durable Task SQL Server provider. - DurableTaskSqlServer = 11 + /// + Unknown = 0x0000, + /// + EntityFramework = 0x0001, + /// + SemanticKernel = 0x0002, + /// + ManagementStudio = 0x0003, + /// + SqlManagementObjects = 0x0004, + /// + DataTierApplicationFramework = 0x0005, + /// + SqlToolsService = 0x0006, + /// + AspNetCoreDistributedSqlServerCache = 0x0007, + /// + EntityFramework6 = 0x0008, + /// + AzureFunctionsSqlExtension = 0x0009, + /// + OrleansAdoNet = 0x000A, + /// + DurableTaskSqlServer = 0x000B } /// @@ -1044,8 +1039,8 @@ public SqlConnection() { } public SqlConnection(string connectionString) { } /// public SqlConnection(string connectionString, Microsoft.Data.SqlClient.SqlCredential credential) { } - /// - public static bool RegisterSqlClientAgent(Microsoft.Data.SqlClient.SqlClientAgent id) { throw null; } + /// + public Microsoft.Data.SqlClient.SqlClientApp SqlClientAppId { get { throw null; } set { } } /// [System.ComponentModel.BrowsableAttribute(false)] diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Connection/SqlConnectionInternal.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Connection/SqlConnectionInternal.cs index ec210e407a..5351e48c8c 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Connection/SqlConnectionInternal.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Connection/SqlConnectionInternal.cs @@ -306,6 +306,12 @@ internal class SqlConnectionInternal : DbConnectionInternal, IDisposable private bool _sessionRecoveryRequested; + /// + /// The middleware application identity of the that caused + /// this physical connection to be created. Reported once, at login. + /// + private readonly SqlClientApp _sqlClientAppId; + private int _threadIdOwningParserLock = -1; // @TODO: Rename to indicate this has to do with routing @@ -344,12 +350,14 @@ internal SqlConnectionInternal( IDbConnectionPool pool = null, Func> accessTokenCallback = null, SspiContextProvider sspiContextProvider = null, - ISqlClientMetrics metrics = null) + ISqlClientMetrics metrics = null, + SqlClientApp sqlClientAppId = SqlClientApp.Unknown) : base(metrics) { Debug.Assert(connectionOptions is not null, "null connectionOptions"); ConnectionOptions = connectionOptions; + _sqlClientAppId = sqlClientAppId; #if DEBUG if (reconnectSessionData != null) @@ -3063,6 +3071,7 @@ private void Login( login.password = ConnectionOptions.Password; login.applicationName = ConnectionOptions.ApplicationName; login.language = _currentLanguage; + login.appId = _sqlClientAppId; if (!login.userInstance) { diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientAgent.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientAgent.cs deleted file mode 100644 index 8a535e19d5..0000000000 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientAgent.cs +++ /dev/null @@ -1,274 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Configuration; -using System.Threading; -using Microsoft.Data.Common; -using Microsoft.Data.SqlClient.Internal; - -#nullable enable - -namespace Microsoft.Data.SqlClient; - -/// -/// Identifies known middleware agents that use Microsoft.Data.SqlClient. -/// -/// -/// Identifiers are positive and fit in 16 bits. The underlying type is -/// so the enum stays CLS-compliant; the range is enforced -/// when an agent is registered. -/// -public enum SqlClientAgent -{ - /// The Microsoft Entity Framework Core SQL Server provider. - EntityFramework = 1, - - /// Microsoft Semantic Kernel. - SemanticKernel = 2, - - /// Microsoft SQL Server Management Studio. - ManagementStudio = 3, - - /// Microsoft SQL Server Management Objects. - SqlManagementObjects = 4, - - /// Microsoft SQL Server Data-Tier Application Framework. - DataTierApplicationFramework = 5, - - /// Microsoft SQL Tools Service. - SqlToolsService = 6, - - /// Microsoft ASP.NET Core distributed SQL Server cache. - AspNetCoreDistributedSqlServerCache = 7, - - /// Microsoft Entity Framework 6 SQL Server provider. - EntityFramework6 = 8, - - /// Microsoft Azure Functions SQL extension. - AzureFunctionsSqlExtension = 9, - - /// Microsoft Orleans ADO.NET providers. - OrleansAdoNet = 10, - - /// Microsoft Durable Task SQL Server provider. - DurableTaskSqlServer = 11 -} - -/// -/// -/// Holds the process-wide agent registration reported in the USERAGENT -/// login feature extension. -/// -/// -/// An agent may be registered at most once per process, either from the -/// application configuration file or programmatically via -/// . The configuration -/// file wins, because it is read during static construction, before any -/// application code can call -/// . -/// -/// -internal static class SqlClientAgentRegistration -{ - // The registered agent identifier, or 0 when no agent is registered. - // - // Written at most once, either by static construction from the application - // configuration file, or by the first Register() call. - private static int s_agentId = LoadFromAppConfig(); - - /// - /// The registered agent, or null when no agent has been registered. - /// - internal static SqlClientAgent? Agent - { - get - { - int id = Volatile.Read(ref s_agentId); - return id == 0 ? null : (SqlClientAgent)id; - } - } - - /// - /// Register the given agent for the lifetime of the process. - /// - /// The agent to register. - /// - /// if this call registered ; - /// otherwise , meaning an agent was already - /// registered and that registration is left unchanged. - /// - /// - /// Losing the race is not an error. An application may load several - /// middleware libraries that each register themselves, and only the first - /// can win; reporting that through the return value keeps the others from - /// having to guard the call. - /// - /// - /// is not a declared agent identifier. - /// - internal static bool Register(SqlClientAgent id) - { - Validate(id); - return Interlocked.CompareExchange(ref s_agentId, (int)id, 0) == 0; - } - - /// - /// - /// Convert a configured agent identifier to a - /// . - /// - /// - /// Both the name of a known agent (case-insensitive) and its numeric - /// identifier are accepted. A numeric identifier that is not yet - /// defined in is accepted, so an agent - /// assigned an identifier after this driver shipped can still be - /// configured. - /// - /// - /// The agent identifier to convert. - /// The agent the identifier names. - /// - /// is not a valid agent identifier. - /// - internal static SqlClientAgent Parse(string value) - { - // Enum.TryParse accepts comma-separated lists and combines them, so - // "EntityFramework,SemanticKernel" would silently yield an unrelated - // agent. Only a single name or number is valid here. - if (value is null - || value.IndexOf(',') >= 0 - || !Enum.TryParse(value, ignoreCase: true, out SqlClientAgent id) - || !IsInRange(id)) - { - throw SQL.InvalidSqlClientAgent(value ?? string.Empty, nameof(value)); - } - - return id; - } - - /// - /// Throw if the given agent is not a declared agent identifier. - /// - /// - /// The public registration API is a closed enum, so an undeclared value - /// is always a caller mistake. Undeclared numeric identifiers remain - /// valid in , where they let an agent assigned an - /// identifier after this driver shipped still be configured. - /// - /// The agent to validate. - /// - /// is not a declared agent identifier. - /// - private static void Validate(SqlClientAgent id) - { - if (!IsInRange(id) || !Enum.IsDefined(typeof(SqlClientAgent), id)) - { - throw SQL.InvalidSqlClientAgent(id.ToString(), nameof(id)); - } - } - - /// - /// Whether the given agent falls within the identifier space reported on - /// the wire. - /// - /// - /// Identifiers are 16-bit and positive. Zero is reserved to mean "no - /// agent registered". - /// - /// The agent to test. - /// True if the agent is in range, false otherwise. - private static bool IsInRange(SqlClientAgent id) => - (int)id > 0 && (int)id <= ushort.MaxValue; - - /// - /// - /// Read the agent registered in the application configuration file. - /// - /// All known exceptions are consumed. - /// - /// - /// The configured agent identifier, or 0 when no valid agent is - /// configured. - /// - private static int LoadFromAppConfig() => - LoadAgent(() => ConfigurationManager.GetSection(SqlClientAgentConfigurationSection.Name)); - - /// - /// - /// Read the agent from the section returned by the given loader. - /// - /// All known exceptions are consumed. - /// - /// - /// Returns the configuration section, or null when it is absent. - /// - /// - /// The configured agent identifier, or 0 when no valid agent is - /// configured. - /// - internal static int LoadAgent(Func getSection) - { - // This runs during static initialization on the login path, so any - // escaping exception would surface as a TypeInitializationException on - // every connection attempt. Telemetry must never break connections. - try - { - object? section = getSection(); - if (section is null) - { - return 0; - } - - if (section is SqlClientAgentConfigurationSection configurationSection) - { - return (int)Parse(configurationSection.Id); - } - - SqlClientEventSource.Log.TryTraceEvent( - "SqlClientAgentRegistration: The SqlClientAgent configuration section has an unexpected type; the agent was not registered."); - } - catch (Exception e) when (ADP.IsCatchableExceptionType(e)) - { - SqlClientEventSource.Log.TryTraceEvent( - "SqlClientAgentRegistration: Unable to load the SqlClientAgent configuration; the agent was not registered: {0}", - e); - } - - return 0; - } -} - -/// -/// -/// The SqlClientAgent application configuration file section, used -/// to register an agent without changing application code. -/// -/// -/// -/// <configSections> -/// <section name="SqlClientAgent" -/// type="Microsoft.Data.SqlClient.SqlClientAgentConfigurationSection, Microsoft.Data.SqlClient" /> -/// </configSections> -/// <SqlClientAgent id="EntityFramework" /> -/// -/// -/// -internal sealed class SqlClientAgentConfigurationSection : ConfigurationSection -{ - /// - /// The name of this configuration section. - /// - internal const string Name = "SqlClientAgent"; - - /// - /// The name or numeric identifier of the agent to register. - /// - [ConfigurationProperty("id", IsRequired = true)] - public string Id - { - get => this["id"] as string ?? string.Empty; - set => this["id"] = value; - } -} diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientApp.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientApp.cs new file mode 100644 index 0000000000..fca56f1d40 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientApp.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#nullable enable + +namespace Microsoft.Data.SqlClient; + +/// +public enum SqlClientApp +{ + /// + Unknown = 0x0000, + /// + EntityFramework = 0x0001, + /// + SemanticKernel = 0x0002, + /// + ManagementStudio = 0x0003, + /// + SqlManagementObjects = 0x0004, + /// + DataTierApplicationFramework = 0x0005, + /// + SqlToolsService = 0x0006, + /// + AspNetCoreDistributedSqlServerCache = 0x0007, + /// + EntityFramework6 = 0x0008, + /// + AzureFunctionsSqlExtension = 0x0009, + /// + OrleansAdoNet = 0x000A, + /// + DurableTaskSqlServer = 0x000B +} diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientDriverProperties.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientDriverProperties.cs new file mode 100644 index 0000000000..8d39ee569e --- /dev/null +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientDriverProperties.cs @@ -0,0 +1,61 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; + +#nullable enable + +namespace Microsoft.Data.SqlClient; + +/// +/// Driver-owned feature flags reported in the Driver Properties part of the +/// USERAGENT login feature extension payload. +/// +/// +/// This part is driver-owned, so its meaning is defined entirely by +/// Microsoft.Data.SqlClient and carries no cross-driver contract. Other +/// drivers use the same part for their own purposes. +/// +[Flags] +internal enum SqlClientDriverProperties : ushort +{ + /// No tracked features are enabled. + None = 0x0000, + + /// + /// The connection pool V2 implementation + /// (Switch.Microsoft.Data.SqlClient.UseConnectionPoolV2) is + /// enabled. + /// + ConnectionPoolV2 = 0x0001 +} + +/// +/// Resolves the flags that describe +/// how this process is configured. +/// +internal static class SqlClientDriverPropertiesResolver +{ + /// + /// The flags describing the current process. + /// + /// + /// The flags are sourced from process-wide switches, so this is stable + /// for the life of the process. + /// + internal static SqlClientDriverProperties Current + { + get + { + SqlClientDriverProperties properties = SqlClientDriverProperties.None; + + if (LocalAppContextSwitches.UseConnectionPoolV2) + { + properties |= SqlClientDriverProperties.ConnectionPoolV2; + } + + return properties; + } + } +} diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs index 63dd6a3eef..014892413b 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs @@ -75,6 +75,7 @@ public sealed partial class SqlConnection : DbConnection, ICloneable private string _connectionString; private int _connectRetryCount; private string _accessToken; // Access Token to be used for token based authentication + private SqlClientApp _sqlClientAppId = SqlClientApp.Unknown; // middleware application identity reported at login // connection resiliency private object _reconnectLock; @@ -381,9 +382,23 @@ public static void RegisterColumnEncryptionKeyStoreProviders(IDictionary - public static bool RegisterSqlClientAgent(SqlClientAgent id) - => SqlClientAgentRegistration.Register(id); + /// + public SqlClientApp SqlClientAppId + { + get => _sqlClientAppId; + set + { + // Identifiers are carried in 16 bits, so anything outside that + // range cannot be reported and is rejected here rather than + // being silently truncated at login. + if ((int)value < 0 || (int)value > ushort.MaxValue) + { + throw SQL.InvalidSqlClientAppId(value, nameof(value)); + } + + _sqlClientAppId = value; + } + } /// public void RegisterColumnEncryptionKeyStoreProvidersOnConnection(IDictionary customProviders) diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionFactory.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionFactory.cs index c3b7bfd13b..cd936c4f44 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionFactory.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionFactory.cs @@ -774,7 +774,8 @@ protected virtual DbConnectionInternal CreateConnection( pool, key.AccessTokenCallback, key.SspiContextProvider, - metrics: Metrics); + metrics: Metrics, + sqlClientAppId: sqlOwningConnection?.SqlClientAppId ?? SqlClientApp.Unknown); } private static DbConnectionPoolGroupOptions CreateConnectionPoolGroupOptions(SqlConnectionOptions connectionOptions) diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlUtil.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlUtil.cs index bf5d20fb7c..5e0afa47ce 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlUtil.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlUtil.cs @@ -1856,9 +1856,13 @@ internal static Exception EmptyProviderName() #endregion Always Encrypted Errors - internal static Exception InvalidSqlClientAgent(string value, string parameterName) + internal static Exception InvalidSqlClientAppId(SqlClientApp value, string parameterName) { - return ADP.ArgumentOutOfRange(StringsHelper.GetString(Strings.SQL_InvalidSqlClientAgent, value), parameterName); + return ADP.ArgumentOutOfRange( + StringsHelper.GetString( + Strings.SQL_InvalidSqlClientAppId, + ((int)value).ToString(CultureInfo.InvariantCulture)), + parameterName); } // diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs index bf8a754350..7800154410 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs @@ -1359,7 +1359,7 @@ internal void TdsLogin( // Capture the payload once so the length reserved below and the // bytes written by WriteLoginData can never disagree, even if an // agent is registered concurrently. - ReadOnlyMemory userAgent = UserAgent.GetUcs2Bytes(SqlClientAgentRegistration.Agent); + ReadOnlyMemory userAgent = UserAgent.GetUcs2Bytes(rec.appId); // calculate and reserve the required bytes for the featureEx length = ApplyFeatureExData( requestedFeatures, diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParserHelperClasses.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParserHelperClasses.cs index 6bcc3f2d41..80b832f91d 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParserHelperClasses.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParserHelperClasses.cs @@ -124,6 +124,7 @@ internal sealed class SqlLogin internal bool readOnlyIntent = false; // read-only intent internal SqlCredential credential; // user id and password in SecureString internal SecureString newSecurePassword; + internal SqlClientApp appId = SqlClientApp.Unknown; // middleware application identity } #nullable enable diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent.cs index e8679edaad..82aeb66b9e 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System; +using System.Diagnostics; using System.Globalization; using System.Runtime.InteropServices; using System.Text; @@ -33,14 +34,14 @@ internal static class UserAgent /// never larger than 256 characters. /// /// - /// The format is pipe ('|') delimited into 7 parts: + /// The format is pipe ('|') delimited into 9 parts: /// - /// 2|MS-MDS|{Driver Version}|{Arch}|{OS Type}|{OS Info}|{Runtime Info} + /// 2|MS-MDS|{Driver Version}|{Arch}|{OS Type}|{OS Info}|{Runtime Info}|{App Id}|{Driver Properties} /// /// - /// This is the base value, and never carries an agent identifier. The - /// payload actually sent at login may append an optional 8th - /// {Agent Id} part; see + /// This is the base value, whose {App Id} part is always + /// 0000. The payload actually sent at login carries the + /// identifier set on the connection; see /// GetUcs2Bytes. /// /// @@ -84,13 +85,17 @@ internal static class UserAgent /// Maximum length is 44 characters. /// /// - /// The {Agent Id} part is never present in this value. It is - /// appended by GetUcs2Bytes when an - /// agent has been registered via - /// - /// RegisterSqlClientAgent - /// . - /// Maximum length is 8 characters. + /// The {App Id} part is the identifier of the application + /// middleware using the driver, serialized as exactly four uppercase + /// hexadecimal characters, zero-padded. It is always present; + /// 0000 means no application identity was reported. Maximum + /// length is 4 characters. + /// + /// + /// The {Driver Properties} part is a driver-owned feature flag + /// value, serialized as exactly four uppercase hexadecimal characters, + /// zero-padded. It is always present. Maximum length is 4 + /// characters. /// /// /// Any characters from the sourced values that are not one of the @@ -127,52 +132,48 @@ internal static class UserAgent /// /// - /// Returns with the registered agent - /// identifier appended as an additional part. + /// Returns the UCS-2 encoded payload reporting the given application + /// identifier. /// /// - /// When is null, is - /// returned unchanged, with no additional part appended. + /// When is + /// , is + /// returned, whose App Id part is 0000. /// /// - /// - /// The registered agent, or null if no agent has been registered. + /// + /// The application identifier set on the connection being logged in. /// /// The UCS-2 encoded payload bytes. - internal static ReadOnlyMemory GetUcs2Bytes(SqlClientAgent? agent) + internal static ReadOnlyMemory GetUcs2Bytes(SqlClientApp app) { - if (agent is null) + if (app == SqlClientApp.Unknown) { return Ucs2Bytes; } - // An agent is registered at most once per process, so a single cached - // entry serves every login. The pair is cached behind one reference so - // readers never observe a torn ReadOnlyMemory. - // - // The identifier space is 16-bit, enforced when the agent is registered. - ushort agentId = (ushort)agent.Value; - AgentPayload? cached = Volatile.Read(ref s_agentPayload); - if (cached is not null && cached.AgentId == agentId) + // Most processes report a single application identifier, so a single + // cached entry serves every login. The pair is cached behind one + // reference so readers never observe a torn ReadOnlyMemory. + AppPayload? cached = Volatile.Read(ref s_appPayload); + if (cached is not null && cached.App == app) { return cached.Ucs2Bytes; } - ReadOnlyMemory bytes = Encoding.Unicode.GetBytes(BuildPayload(agentId)); - Volatile.Write(ref s_agentPayload, new AgentPayload(agentId, bytes)); + ReadOnlyMemory bytes = Encoding.Unicode.GetBytes(BuildPayload(app)); + Volatile.Write(ref s_appPayload, new AppPayload(app, bytes)); return bytes; } /// /// Build the payload string from the current runtime environment, - /// appending the given agent id when one is supplied. + /// reporting the given application identifier. /// - /// - /// The agent id to append, or null to omit the Agent Id part. - /// + /// The application identifier to report. /// The payload string value. - private static string BuildPayload(ushort? agentId) => + private static string BuildPayload(SqlClientApp app) => Build( MaxLenOverall, PayloadVersion, @@ -182,7 +183,24 @@ private static string BuildPayload(ushort? agentId) => s_osType, RuntimeInformation.OSDescription, RuntimeInformation.FrameworkDescription, - agentId); + ToAppId(app), + (ushort)SqlClientDriverPropertiesResolver.Current); + + /// + /// Narrow an application identifier to the 16 bits the payload reports. + /// + /// + /// rejects values outside the + /// 16-bit range, so this conversion is always lossless. + /// + /// The application identifier to narrow. + /// The narrowed application identifier. + private static ushort ToAppId(SqlClientApp app) + { + Debug.Assert((int)app >= 0 && (int)app <= ushort.MaxValue); + + return (ushort)app; + } /// /// Static construction builds the Client Interface Name. All known @@ -219,7 +237,7 @@ static UserAgent() s_osType = osType; // Build it! - Value = BuildPayload(agentId: null); + Value = BuildPayload(SqlClientApp.Unknown); // Convert it to UCS-2 bytes. // @@ -260,9 +278,13 @@ static UserAgent() /// /// The value of the Runtime Info part. /// - /// - /// The value of the optional Agent Id part. When null, no Agent Id part - /// is appended. + /// + /// The value of the App Id part, serialized as four uppercase + /// hexadecimal characters. + /// + /// + /// The value of the Driver Properties part, serialized as four uppercase + /// hexadecimal characters. /// /// /// The payload string value, never null, never empty, and never longer @@ -277,7 +299,8 @@ internal static string Build( string osType, string osInfo, string runtimeInfo, - ushort? agentId = null) + ushort appId = 0, + ushort driverProperties = 0) { string result; @@ -320,16 +343,19 @@ internal static string Build( // Add the Runtime Info, truncating to its max length. name.Append(Truncate(Clean(runtimeInfo), MaxLenRuntimeInfo)); + name.Append('|'); - // Add the Agent Id only when an agent has been registered. - if (agentId.HasValue) - { - name.Append('|'); - name.Append( - Truncate( - Clean(agentId.Value.ToString(CultureInfo.InvariantCulture)), - MaxLenAgentId)); - } + // Add the App Id. It is fixed-width hexadecimal, so it can never + // exceed its maximum length and needs no cleaning. + string appIdPart = FormatHex(appId); + Debug.Assert(appIdPart.Length == MaxLenAppId); + name.Append(appIdPart); + name.Append('|'); + + // Add the Driver Properties, on the same terms as the App Id. + string driverPropertiesPart = FormatHex(driverProperties); + Debug.Assert(driverPropertiesPart.Length == MaxLenDriverProperties); + name.Append(driverPropertiesPart); // Remember the name we've built up. result = name.ToString(); @@ -340,7 +366,8 @@ internal static string Build( // value. result = $"{payloadVersion}|{driverName}|{Unknown}|{Unknown}|" + - $"{Unknown}|{Unknown}|{Unknown}"; + $"{Unknown}|{Unknown}|{Unknown}|{FormatHex(appId)}|" + + $"{FormatHex(driverProperties)}"; } // Truncate to our max length if necessary. @@ -457,6 +484,20 @@ internal static string Clean(string? value) } } + /// + /// Format the given value as exactly four uppercase hexadecimal + /// characters, zero-padded. + /// + /// + /// A never needs more than four hexadecimal + /// characters, so the result is always exactly + /// characters and can never be truncated. + /// + /// The value to format. + /// The formatted value. + internal static string FormatHex(ushort value) => + value.ToString("X4", CultureInfo.InvariantCulture); + /// /// Truncate the given value to the given max length, and return the /// result. @@ -502,7 +543,8 @@ internal static string Truncate(string value, ushort maxLength) private const ushort MaxLenOsType = 10; private const ushort MaxLenOsInfo = 44; private const ushort MaxLenRuntimeInfo = 44; - private const ushort MaxLenAgentId = 8; + private const ushort MaxLenAppId = 4; + private const ushort MaxLenDriverProperties = 4; // The OS Type values we promise in our API. private const string Windows = "Windows"; @@ -517,25 +559,26 @@ internal static string Truncate(string value, ushort maxLength) // unknown, invalid, or when errors occur. private const string Unknown = "Unknown"; - // The OS Type resolved during static construction, retained so agent - // payloads are built from the same value as Value. + // The OS Type resolved during static construction, retained so payloads + // built later use the same value as Value. private static readonly string s_osType; - // The payload for the registered agent, built on first use. - private static AgentPayload? s_agentPayload; + // The payload for the most recently requested application identifier, + // built on first use. + private static AppPayload? s_appPayload; /// - /// Pairs a payload with the agent identifier it was built for. + /// Pairs a payload with the application identifier it was built for. /// - private sealed class AgentPayload + private sealed class AppPayload { - internal AgentPayload(ushort agentId, ReadOnlyMemory ucs2Bytes) + internal AppPayload(SqlClientApp app, ReadOnlyMemory ucs2Bytes) { - AgentId = agentId; + App = app; Ucs2Bytes = ucs2Bytes; } - internal ushort AgentId { get; } + internal SqlClientApp App { get; } internal ReadOnlyMemory Ucs2Bytes { get; } } diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs b/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs index 80fe5a6084..cff74a5ce5 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs @@ -2815,14 +2815,14 @@ internal static string SQL_ActiveDirectoryInvalidStateTransition { } /// - /// Looks up a localized string similar to The SqlClient agent identifier '{0}' is invalid.. + /// Looks up a localized string similar to The SqlClient application identifier '{0}' is outside the supported range of 0 to 65535.. /// - internal static string SQL_InvalidSqlClientAgent { + internal static string SQL_InvalidSqlClientAppId { get { - return ResourceManager.GetString("SQL_InvalidSqlClientAgent", resourceCulture); + return ResourceManager.GetString("SQL_InvalidSqlClientAppId", resourceCulture); } } - + /// /// Looks up a localized string similar to Unsupported state: '{0}'.. /// diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx b/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx index f52f0515b5..05e7ed5491 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx @@ -2199,8 +2199,8 @@ Cannot transition from state '{0}' to '{1}'. - - The SqlClient agent identifier '{0}' is invalid. + + The SqlClient application identifier '{0}' is outside the supported range of 0 to 65535. Unsupported state: '{0}'. diff --git a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlClientAgentConfigurationTests.cs b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlClientAgentConfigurationTests.cs deleted file mode 100644 index 30f10cb603..0000000000 --- a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlClientAgentConfigurationTests.cs +++ /dev/null @@ -1,30 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using Xunit; - -namespace Microsoft.Data.SqlClient.Tests -{ - /// - /// Verifies that an agent registered in the application configuration file consumes the single - /// process-wide registration. - /// - public class SqlClientAgentConfigurationTests - { - /// - /// Verifies that an agent registered in the application configuration file consumes the - /// single process-wide registration, so a later programmatic registration is rejected. - /// - /// - /// This cannot be verified on .NET because the test host substitutes its own configuration - /// file for the one built alongside this assembly. - /// - [ConditionalFact(typeof(TestUtility), nameof(TestUtility.IsNetFramework))] - public void AppConfigAgent_PreventsProgrammaticRegistration() - { - Assert.False(SqlConnection.RegisterSqlClientAgent(SqlClientAgent.SemanticKernel)); - } - } -} diff --git a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/app.config b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/app.config index 1de2641418..9fc08c65a7 100644 --- a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/app.config +++ b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/app.config @@ -3,9 +3,7 @@
-
- diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionTests.cs index f4e8e03f11..f1d0549392 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionTests.cs @@ -28,9 +28,6 @@ namespace Microsoft.Data.SqlClient.UnitTests.SimulatedServerTests [Collection(SimulatedServerTestCollection.Name)] public class ConnectionTests { - private static readonly Lazy s_sqlClientAgentRegistered = new(() => - SqlConnection.RegisterSqlClientAgent(SqlClientAgent.EntityFramework)); - [Fact] public void ConnectionTest() { @@ -1175,8 +1172,8 @@ public void TestConnWithVectorFeatExtVersionNegotiation(bool expectedConnectionR } /// - /// Verifies that LOGIN7 sends the USERAGENT payload with the globally registered agent - /// identifier appended, regardless of whether the server acknowledges the extension. + /// Verifies that LOGIN7 sends the USERAGENT payload carrying the connection's application + /// identity, regardless of whether the server acknowledges the extension. /// [Theory] // Allow the server to ack. @@ -1231,21 +1228,19 @@ public void TestConnWithUserAgentFeatureExtension(bool sendAck) Pooling = false, // No pooling needed; avoids leaking a pooled connection to this ephemeral port }.ConnectionString; - _ = s_sqlClientAgentRegistered.Value; using var connection = new SqlConnection(connStr); + connection.SqlClientAppId = SqlClientApp.EntityFramework; connection.Open(); // Verify the connection itself succeeded Assert.Equal(ConnectionState.Open, connection.State); - // A second registration loses to the first and leaves it in place. - Assert.False(SqlConnection.RegisterSqlClientAgent(SqlClientAgent.SemanticKernel)); // Verify client did offer UserAgent and captured conditions hold Assert.True(loginFound, "Expected UserAgent extension in LOGIN7"); Assert.True(firstFeatureIsUserAgent); Assert.True(tokenWasNotNull); Assert.True(dataLengthAtLeast1); - Assert.Equal(UserAgent.GetUcs2Bytes(SqlClientAgent.EntityFramework).ToArray(), observedPayload); + Assert.Equal(UserAgent.GetUcs2Bytes(SqlClientApp.EntityFramework).ToArray(), observedPayload); // TODO: Confirm the server sent an Ack by reading log message from SqlInternalConnectionTds } diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlClientAgentTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlClientAgentTests.cs deleted file mode 100644 index dd341f5319..0000000000 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlClientAgentTests.cs +++ /dev/null @@ -1,218 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Configuration; -using System.IO; -using Xunit; - -namespace Microsoft.Data.SqlClient.UnitTests; - -/// -/// Verifies agent identifier parsing and validation. -/// -public class SqlClientAgentTests -{ - /// - /// Ensures the published underlying type stays Int32, which keeps the enum CLS-compliant. - /// - [Fact] - public void UnderlyingType_IsInt32() - => Assert.Equal(typeof(int), Enum.GetUnderlyingType(typeof(SqlClientAgent))); - - /// Ensures published Microsoft agent identifiers remain stable. - [Fact] - public void KnownAgentIdentifiers_AreStable() - { - Assert.Equal(1, (int)SqlClientAgent.EntityFramework); - Assert.Equal(2, (int)SqlClientAgent.SemanticKernel); - Assert.Equal(3, (int)SqlClientAgent.ManagementStudio); - Assert.Equal(4, (int)SqlClientAgent.SqlManagementObjects); - Assert.Equal(5, (int)SqlClientAgent.DataTierApplicationFramework); - Assert.Equal(6, (int)SqlClientAgent.SqlToolsService); - Assert.Equal(7, (int)SqlClientAgent.AspNetCoreDistributedSqlServerCache); - Assert.Equal(8, (int)SqlClientAgent.EntityFramework6); - Assert.Equal(9, (int)SqlClientAgent.AzureFunctionsSqlExtension); - Assert.Equal(10, (int)SqlClientAgent.OrleansAdoNet); - Assert.Equal(11, (int)SqlClientAgent.DurableTaskSqlServer); - } - - /// Verifies configuration accepts enum names and forward-compatible numeric identifiers. - [Theory] - [InlineData("SqlToolsService", 6)] - [InlineData("42", 42)] - public void Parse_AcceptsNamedAndNumericIdentifiers(string value, int expected) - => Assert.Equal(expected, (int)SqlClientAgentRegistration.Parse(value)); - - /// Verifies invalid and zero identifiers are rejected. - [Theory] - [InlineData("")] - [InlineData("not-an-agent")] - [InlineData("0")] - // Enum.TryParse would otherwise combine these into an unrelated agent. - [InlineData("EntityFramework,SemanticKernel")] - [InlineData("1,2")] - // Identifiers are 16-bit and positive. - [InlineData("-1")] - [InlineData("70000")] - public void Parse_RejectsInvalidIdentifiers(string value) - => Assert.ThrowsAny(() => SqlClientAgentRegistration.Parse(value)); - - /// Verifies undeclared identifiers are rejected by the public registration API. - [Theory] - [InlineData(0)] - [InlineData(42)] - [InlineData(-1)] - public void Register_RejectsUndeclaredIdentifiers(int id) - => Assert.Throws( - () => SqlConnection.RegisterSqlClientAgent((SqlClientAgent)id)); - - /// - /// Verifies registration reports whether it won, so a second middleware that registers after - /// the first does not fault the application. - /// - [Fact] - public void Register_ReportsWhetherItWon() - { - // The first caller to register in this process wins; every later caller loses and leaves - // the winning registration in place. Which of the two happens here depends on whether - // another test in this assembly already registered, so accept either and assert that the - // registration is single-valued afterwards. - bool won = SqlConnection.RegisterSqlClientAgent(SqlClientAgent.EntityFramework); - SqlClientAgent? registered = SqlClientAgentRegistration.Agent; - - Assert.NotNull(registered); - if (won) - { - Assert.Equal(SqlClientAgent.EntityFramework, registered); - } - - // Whoever won, a subsequent registration always loses and cannot replace the agent. - Assert.False(SqlConnection.RegisterSqlClientAgent(SqlClientAgent.SemanticKernel)); - Assert.Equal(registered, SqlClientAgentRegistration.Agent); - } - - /// - /// Verifies the SqlClientAgent configuration section is declared correctly and yields the - /// expected agent. - /// - /// The host configuration file cannot be exercised here because the test host substitutes its - /// own, so the section is loaded from a mapped configuration file instead. - /// - [Theory] - [InlineData("EntityFramework", 1)] - [InlineData("managementstudio", 3)] - [InlineData("42", 42)] - public void ConfigurationSection_YieldsAgent(string id, int expected) - { - SqlClientAgentConfigurationSection section = LoadSection(id); - - Assert.Equal(expected, (int)SqlClientAgentRegistration.Parse(section.Id)); - } - - /// - /// Verifies an invalid configured identifier is rejected rather than silently accepted. - /// - [Fact] - public void ConfigurationSection_RejectsInvalidId() - { - SqlClientAgentConfigurationSection section = LoadSection("not-an-agent"); - - Assert.ThrowsAny(() => SqlClientAgentRegistration.Parse(section.Id)); - } - - /// - /// Verifies a malformed configuration file is consumed rather than escaping as a - /// TypeInitializationException on the login path. - /// - [Fact] - public void LoadAgent_MalformedConfigurationFile_YieldsNoAgent() - { - string path = WriteConfig("Verifies a valid configured identifier is loaded.
- [Fact] - public void LoadAgent_ValidId_YieldsAgent() - => Assert.Equal(6, SqlClientAgentRegistration.LoadAgent(() => LoadSection("SqlToolsService"))); - - /// Verifies an absent section yields no agent. - [Fact] - public void LoadAgent_NoSection_YieldsNoAgent() - => Assert.Equal(0, SqlClientAgentRegistration.LoadAgent(() => null)); - - /// Verifies a section of an unexpected type is consumed and yields no agent. - [Fact] - public void LoadAgent_UnexpectedSectionType_YieldsNoAgent() - => Assert.Equal(0, SqlClientAgentRegistration.LoadAgent(() => "not-a-section")); - - /// Verifies a throwing section loader is consumed and yields no agent. - [Fact] - public void LoadAgent_ThrowingLoader_YieldsNoAgent() - => Assert.Equal( - 0, - SqlClientAgentRegistration.LoadAgent( - () => throw new ConfigurationErrorsException("bad configuration"))); - - /// - /// Load the SqlClientAgent section from a temporary configuration file containing the given id. - /// - private static SqlClientAgentConfigurationSection LoadSection(string id) - { - string path = WriteConfig( - "" + - "" + - "" + - $"
" + - "" + - $"<{SqlClientAgentConfigurationSection.Name} id=\"{id}\" />" + - ""); - - try - { - return Assert.IsType(OpenSection(path)); - } - finally - { - File.Delete(path); - } - } - - /// - /// Write the given content to a temporary configuration file and return its path. - /// - private static string WriteConfig(string content) - { - string path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.config"); - File.WriteAllText(path, content); - return path; - } - - /// - /// Read the SqlClientAgent section from the configuration file at the given path. - /// - /// The host configuration file cannot be exercised here because the test host substitutes its - /// own, so a mapped configuration file is used instead. - /// - private static object OpenSection(string path) - => ConfigurationManager.OpenMappedExeConfiguration( - new ExeConfigurationFileMap { ExeConfigFilename = path }, - ConfigurationUserLevel.None) - .GetSection(SqlClientAgentConfigurationSection.Name); -} diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlClientAppTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlClientAppTests.cs new file mode 100644 index 0000000000..6bdd7d4cf3 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlClientAppTests.cs @@ -0,0 +1,125 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using Microsoft.Data.SqlClient; +using Xunit; + +namespace Microsoft.Data.SqlClient.UnitTests; + +/// +/// Tests for the application identifier registry. +/// +public class SqlClientAppTests +{ + /// + /// Verifies the enum is CLS-compliant, so it is usable from every .NET + /// language. + /// + [Fact] + public void UnderlyingType_Is_Int() + { + Assert.Equal(typeof(int), Enum.GetUnderlyingType(typeof(SqlClientApp))); + } + + /// + /// Verifies the default value reports no application identity. + /// + [Fact] + public void Default_Is_Unknown() + { + Assert.Equal(SqlClientApp.Unknown, default(SqlClientApp)); + Assert.Equal(0, (int)SqlClientApp.Unknown); + } + + /// + /// Verifies the reserved identifiers keep their assigned values, since + /// changing one would silently re-map an application's telemetry. + /// + [Theory] + [InlineData(SqlClientApp.EntityFramework, 0x0001)] + [InlineData(SqlClientApp.SemanticKernel, 0x0002)] + [InlineData(SqlClientApp.ManagementStudio, 0x0003)] + [InlineData(SqlClientApp.SqlManagementObjects, 0x0004)] + [InlineData(SqlClientApp.DataTierApplicationFramework, 0x0005)] + [InlineData(SqlClientApp.SqlToolsService, 0x0006)] + [InlineData(SqlClientApp.AspNetCoreDistributedSqlServerCache, 0x0007)] + [InlineData(SqlClientApp.EntityFramework6, 0x0008)] + [InlineData(SqlClientApp.AzureFunctionsSqlExtension, 0x0009)] + [InlineData(SqlClientApp.OrleansAdoNet, 0x000A)] + [InlineData(SqlClientApp.DurableTaskSqlServer, 0x000B)] + public void Members_Have_Stable_Values(SqlClientApp app, int expected) + { + Assert.Equal(expected, (int)app); + } + + /// + /// Verifies an unregistered identifier can be reported by casting, which + /// keeps the API forward compatible with identifiers added later. + /// + [Fact] + public void Unregistered_Identifier_Is_Accepted() + { + SqlClientApp app = (SqlClientApp)0xC001; + + Assert.False(Enum.IsDefined(typeof(SqlClientApp), app)); + + using SqlConnection connection = new(); + connection.SqlClientAppId = app; + + Assert.Equal(app, connection.SqlClientAppId); + } + + /// + /// Verifies the boundaries of the 16-bit identifier space are accepted, + /// since the payload reports the identifier in exactly 16 bits. + /// + [Theory] + [InlineData(0)] + [InlineData(ushort.MaxValue)] + public void Identifier_In_Range_Is_Accepted(int value) + { + using SqlConnection connection = new(); + + connection.SqlClientAppId = (SqlClientApp)value; + + Assert.Equal(value, (int)connection.SqlClientAppId); + } + + /// + /// Verifies an identifier outside the 16-bit space is rejected rather than + /// silently truncated when the payload is built. + /// + [Theory] + [InlineData(-1)] + [InlineData(ushort.MaxValue + 1)] + [InlineData(int.MaxValue)] + [InlineData(int.MinValue)] + public void Identifier_Out_Of_Range_Throws(int value) + { + using SqlConnection connection = new(); + + Assert.Throws( + () => connection.SqlClientAppId = (SqlClientApp)value); + + // The rejected value is not retained. + Assert.Equal(SqlClientApp.Unknown, connection.SqlClientAppId); + } + + /// + /// Verifies the connection reports no application identity until one is + /// assigned, and round-trips the value it is given. + /// + [Fact] + public void SqlConnection_SqlClientAppId_RoundTrips() + { + using SqlConnection connection = new(); + + Assert.Equal(SqlClientApp.Unknown, connection.SqlClientAppId); + + connection.SqlClientAppId = SqlClientApp.SemanticKernel; + + Assert.Equal(SqlClientApp.SemanticKernel, connection.SqlClientAppId); + } +} diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentTests.cs index a79ed50f1c..322ed4691e 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentTests.cs @@ -72,10 +72,11 @@ public void Value_Runtime_Parts() // // The format should be: // - // 2|MS-MDS|{Driver Version}|{Arch}|{OS Type}|{OS Info}|{Runtime Info} + // 2|MS-MDS|{Driver Version}|{Arch}|{OS Type}|{OS Info}|{Runtime Info}| + // {App Id}|{Driver Properties} // var parts = value.Split('|'); - Assert.Equal(7, parts.Length); + Assert.Equal(9, parts.Length); Assert.Equal("2", parts[0]); Assert.Equal("MS-MDS", parts[1]); Assert.Equal(ThisAssembly.PackageVersion, parts[2]); @@ -116,6 +117,14 @@ public void Value_Runtime_Parts() // Runtime Info must be non-empty and 44 characters or less. Assert.True(parts[6] == "Unknown" || parts[6].Length > 0); Assert.True(parts[6].Length <= 44); + + // App Id defaults to Unknown, and is always four hexadecimal + // characters. + Assert.Equal("0000", parts[7]); + + // Driver Properties is always four hexadecimal characters. + Assert.Equal(4, parts[8].Length); + Assert.Matches("^[0-9A-F]{4}$", parts[8]); } /// @@ -150,56 +159,69 @@ public void Ucs2Bytes_Runtime_Parts() } /// - /// Test that no agent part is appended when no agent is registered. + /// Test that the default payload is reused when no application identity is + /// set on the connection. /// [Fact] - public void GetUcs2Bytes_No_Agent_Returns_Value() + public void GetUcs2Bytes_Unknown_App_Returns_Value() { - var bytes = UserAgent.GetUcs2Bytes(agent: null); + var bytes = UserAgent.GetUcs2Bytes(SqlClientApp.Unknown); Assert.Equal(UserAgent.Ucs2Bytes.ToArray(), bytes.ToArray()); - Assert.Equal(7, Decode(bytes).Split('|').Length); + Assert.Equal(9, Decode(bytes).Split('|').Length); } /// - /// Test that a registered agent is appended as an additional part, leaving - /// the other parts unchanged. + /// Test that an application identity is reported in the App Id part, + /// leaving the other parts unchanged. /// [Fact] - public void GetUcs2Bytes_Agent_Appends_Agent_Id() + public void GetUcs2Bytes_App_Sets_App_Id() { - string value = Decode(UserAgent.GetUcs2Bytes(SqlClientAgent.SemanticKernel)); + string value = Decode(UserAgent.GetUcs2Bytes(SqlClientApp.SemanticKernel)); - _output.WriteLine($"UserAgent with agent: {value}"); - - Assert.Equal($"{UserAgent.Value}|2", value); + _output.WriteLine($"UserAgent with app: {value}"); var parts = value.Split('|'); - Assert.Equal(8, parts.Length); - Assert.Equal("2", parts[7]); + Assert.Equal(9, parts.Length); + Assert.Equal("0002", parts[7]); + + // Every other part matches the default payload. + var defaultParts = UserAgent.Value.Split('|'); + for (int i = 0; i < parts.Length; ++i) + { + if (i != 7) + { + Assert.Equal(defaultParts[i], parts[i]); + } + } } /// - /// Test that the agent payload is built once and reused across logins. + /// Test that the payload for an application identity is built once and + /// reused across logins. /// [Fact] - public void GetUcs2Bytes_Agent_Reuses_Payload() + public void GetUcs2Bytes_App_Reuses_Payload() { Assert.True( - UserAgent.GetUcs2Bytes(SqlClientAgent.ManagementStudio).Span.Overlaps( - UserAgent.GetUcs2Bytes(SqlClientAgent.ManagementStudio).Span)); + UserAgent.GetUcs2Bytes(SqlClientApp.ManagementStudio).Span.Overlaps( + UserAgent.GetUcs2Bytes(SqlClientApp.ManagementStudio).Span)); } /// - /// Test that the Build() function appends the agent id and truncates it to - /// its max length. + /// Test that the Build() function emits the App Id and Driver Properties as + /// four uppercase hexadecimal characters. /// [Theory] - [InlineData(null, "2|A|B|X64|C|D|E")] - [InlineData((ushort)0, "2|A|B|X64|C|D|E|0")] - [InlineData((ushort)7, "2|A|B|X64|C|D|E|7")] - [InlineData(ushort.MaxValue, "2|A|B|X64|C|D|E|65535")] - public void Build_Agent_Id(ushort? agentId, string expected) + [InlineData((ushort)0, (ushort)0, "2|A|B|X64|C|D|E|0000|0000")] + [InlineData((ushort)7, (ushort)1, "2|A|B|X64|C|D|E|0007|0001")] + [InlineData((ushort)0x00AB, (ushort)0, "2|A|B|X64|C|D|E|00AB|0000")] + [InlineData(ushort.MaxValue, ushort.MaxValue, "2|A|B|X64|C|D|E|FFFF|FFFF")] + public void Build_App_Id_And_Driver_Properties( + ushort appId, + ushort driverProperties, + string expected) { Assert.Equal( expected, @@ -212,7 +234,8 @@ public void Build_Agent_Id(ushort? agentId, string expected) osType: "C", osInfo: "D", runtimeInfo: "E", - agentId: agentId)); + appId: appId, + driverProperties: driverProperties)); } /// @@ -247,6 +270,9 @@ private static string Decode(ReadOnlyMemory bytes) => [InlineData(13, "2|A|B|X64|C|D")] [InlineData(14, "2|A|B|X64|C|D|")] [InlineData(15, "2|A|B|X64|C|D|E")] + [InlineData(16, "2|A|B|X64|C|D|E|")] + [InlineData(20, "2|A|B|X64|C|D|E|0000")] + [InlineData(25, "2|A|B|X64|C|D|E|0000|0000")] public void Build_Truncate_Overall(ushort maxLen, string expected) { Assert.Equal( @@ -276,7 +302,7 @@ public void Build_Truncate_Payload_Version() // The payload version is longer than its per-field max length of 2. Assert.Equal( - "12|A|B|X64|C|D|E", + "12|A|B|X64|C|D|E|0000|0000", UserAgent.Build( 128, "1234", "A", "B", Architecture.X64, "C", "D", "E")); } @@ -295,7 +321,7 @@ public void Build_Truncate_Driver_Name() // The driver name is longer than its per-field max length of 12. Assert.Equal( - "2|LongDriverNa|B|X64|C|D|E", + "2|LongDriverNa|B|X64|C|D|E|0000|0000", UserAgent.Build( 128, "2", "LongDriverName", "B", Architecture.X64, "C", "D", "E")); @@ -316,7 +342,7 @@ public void Build_Truncate_Driver_Version() // The driver version is longer than its per-field max length of 24. Assert.Equal( - "2|A|ReallyLongDriverVersionS|X64|C|D|E", + "2|A|ReallyLongDriverVersionS|X64|C|D|E|0000|0000", UserAgent.Build( 128, "2", "A", "ReallyLongDriverVersionString", Architecture.X64, "C", "D", "E")); @@ -340,7 +366,7 @@ public void Build_Truncate_Arch() #if NET // The Architecture is longer than its per-field max length of 10. Assert.Equal( - "2|A|B|LoongArch6|C|D|E", + "2|A|B|LoongArch6|C|D|E|0000|0000", UserAgent.Build( 128, "2", "A", "B", Architecture.LoongArch64, "C", "D", "E")); #endif @@ -360,7 +386,7 @@ public void Build_Truncate_OS_Type() // The OS Type is longer than its per-field max length of 10. Assert.Equal( - "2|A|B|X64|VeryLongOs|D|E", + "2|A|B|X64|VeryLongOs|D|E|0000|0000", UserAgent.Build( 128, "2", "A", "B", Architecture.X64, "VeryLongOsName", "D", "E")); @@ -380,7 +406,7 @@ public void Build_Truncate_OS_Info() // The OS Type is longer than its per-field max length of 44. Assert.Equal( - "2|A|B|X64|C|01234567890123456789012345678901234567890123|E", + "2|A|B|X64|C|01234567890123456789012345678901234567890123|E|0000|0000", UserAgent.Build( 128, "2", "A", "B", Architecture.X64, "C", "01234567890123456789012345678901234567890123456789", @@ -402,7 +428,7 @@ public void Build_Truncate_Runtime_Info() // The Runtime Type is longer than its per-field max length of 44. Assert.Equal( - "2|A|B|X64|C|D|01234567890123456789012345678901234567890123", + "2|A|B|X64|C|D|01234567890123456789012345678901234567890123|0000|0000", UserAgent.Build( 128, "2", "A", "B", Architecture.X64, "C", "D", "01234567890123456789012345678901234567890123456789")); @@ -433,7 +459,7 @@ public void Build_Truncate_Most() "D01234567890123456789012345678901234567890123456789", // Runtime Info > 44 chars. "E01234567890123456789012345678901234567890123456789"); - Assert.Equal(145, name.Length); + Assert.Equal(155, name.Length); Assert.Equal( "12|" + "A01234567890|" + @@ -441,7 +467,8 @@ public void Build_Truncate_Most() "X64|" + "C012345678|" + "D0123456789012345678901234567890123456789012|" + - "E0123456789012345678901234567890123456789012", + "E0123456789012345678901234567890123456789012|" + + "0000|0000", name); } @@ -472,7 +499,7 @@ public void Build_Truncate_All() "D01234567890123456789012345678901234567890123456789", // Runtime Info > 44 chars. "E01234567890123456789012345678901234567890123456789"); - Assert.Equal(152, name.Length); + Assert.Equal(162, name.Length); Assert.Equal( "12|" + "A01234567890|" + @@ -480,7 +507,8 @@ public void Build_Truncate_All() "LoongArch6|" + "C012345678|" + "D0123456789012345678901234567890123456789012|" + - "E0123456789012345678901234567890123456789012", + "E0123456789012345678901234567890123456789012|" + + "0000|0000", name); } #endif From 1510d6f010d3b771b6802b8d9780200b09f1347b Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Thu, 3 Sep 2026 15:12:11 -0700 Subject: [PATCH 09/12] Preserve application identity across clones, add sqlpackage - SqlConnection(SqlConnection) now copies the application identity, so ICloneable.Clone no longer resets a configured identity to Unknown. - Add SqlClientApp.SqlPackage. sqlpackage builds on the Data-Tier Application Framework but reports its own identifier so command-line use can be told apart from other callers of that framework. - Split the driver properties mapping out of the switch it reads, so the mapping can be tested. The switches are cached for the life of the process, which makes them impractical to vary in a test. - Document how the identity behaves under pooling. It is not part of the pool key, so a pooled connection reports the identity that created the physical connection, and connections opened in the background to satisfy Min Pool Size report Unknown. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: efbed43f-1014-45d7-a9f6-9f04711c281e --- .../Microsoft.Data.SqlClient/SqlClientApp.xml | 12 +++++ .../SqlConnection.xml | 13 +++-- .../ref/Microsoft.Data.SqlClient.cs | 4 +- .../Microsoft/Data/SqlClient/SqlClientApp.cs | 4 +- .../SqlClient/SqlClientDriverProperties.cs | 34 ++++++++++---- .../Microsoft/Data/SqlClient/SqlConnection.cs | 1 + .../tests/UnitTests/SqlClientAppTests.cs | 47 +++++++++++++++++++ 7 files changed, 100 insertions(+), 15 deletions(-) diff --git a/doc/snippets/Microsoft.Data.SqlClient/SqlClientApp.xml b/doc/snippets/Microsoft.Data.SqlClient/SqlClientApp.xml index b9f9746d9b..a5cad14cf9 100644 --- a/doc/snippets/Microsoft.Data.SqlClient/SqlClientApp.xml +++ b/doc/snippets/Microsoft.Data.SqlClient/SqlClientApp.xml @@ -125,5 +125,17 @@ 11 + + + The sqlpackage command-line tool. + + + 12 + + + sqlpackage is built on the Data-Tier Application Framework, but reports its own identifier so that + command-line use can be told apart from other callers of that framework. + + diff --git a/doc/snippets/Microsoft.Data.SqlClient/SqlConnection.xml b/doc/snippets/Microsoft.Data.SqlClient/SqlConnection.xml index 5b24f08eac..15f1ad233f 100644 --- a/doc/snippets/Microsoft.Data.SqlClient/SqlConnection.xml +++ b/doc/snippets/Microsoft.Data.SqlClient/SqlConnection.xml @@ -2324,9 +2324,16 @@ The following sample tries to open a connection to an invalid database to simula a value to that type, provided it is within the 16-bit range the protocol allows. - The identity is sent once, during login, so it must be set before the connection is opened. When pooling is - enabled, the value is reported only when a new physical connection is established; a connection served from - the pool reports the identity of the connection that created it. + The identity is sent once, during login, so it must be set before the connection is opened. + + + When pooling is enabled the value is reported only while establishing a new physical connection, and it is + not part of the pool key. A connection served from the pool therefore reports the identity of whichever + connection caused that physical connection to be created, and physical connections opened in the background + to satisfy Min Pool Size report + . Applications that mix identities over one + connection string should treat this telemetry as indicative rather than exact, or disable pooling where an + exact attribution is required. diff --git a/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs b/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs index 8b9e1fa689..b88678a750 100644 --- a/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs +++ b/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs @@ -618,7 +618,9 @@ public enum SqlClientApp /// OrleansAdoNet = 0x000A, /// - DurableTaskSqlServer = 0x000B + DurableTaskSqlServer = 0x000B, + /// + SqlPackage = 0x000C } /// diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientApp.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientApp.cs index fca56f1d40..55cdea2b9d 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientApp.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientApp.cs @@ -32,5 +32,7 @@ public enum SqlClientApp /// OrleansAdoNet = 0x000A, /// - DurableTaskSqlServer = 0x000B + DurableTaskSqlServer = 0x000B, + /// + SqlPackage = 0x000C } diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientDriverProperties.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientDriverProperties.cs index 8d39ee569e..6975475c31 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientDriverProperties.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientDriverProperties.cs @@ -44,18 +44,32 @@ internal static class SqlClientDriverPropertiesResolver /// The flags are sourced from process-wide switches, so this is stable /// for the life of the process. /// - internal static SqlClientDriverProperties Current - { - get - { - SqlClientDriverProperties properties = SqlClientDriverProperties.None; + internal static SqlClientDriverProperties Current => + Resolve(LocalAppContextSwitches.UseConnectionPoolV2); - if (LocalAppContextSwitches.UseConnectionPoolV2) - { - properties |= SqlClientDriverProperties.ConnectionPoolV2; - } + /// + /// Maps the process configuration to the flags that describe it. + /// + /// + /// Whether the connection pool V2 implementation is enabled. + /// + /// + /// The flags describing the supplied configuration. + /// + /// + /// The mapping is kept separate from because the + /// switches it reads are cached for the life of the process, which makes + /// them impractical to vary in a test. + /// + internal static SqlClientDriverProperties Resolve(bool useConnectionPoolV2) + { + SqlClientDriverProperties properties = SqlClientDriverProperties.None; - return properties; + if (useConnectionPoolV2) + { + properties |= SqlClientDriverProperties.ConnectionPoolV2; } + + return properties; } } diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs index 014892413b..a6b6700b6d 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs @@ -263,6 +263,7 @@ private SqlConnection(SqlConnection connection) _accessToken = connection._accessToken; _accessTokenCallback = connection._accessTokenCallback; + _sqlClientAppId = connection._sqlClientAppId; // CopyFrom retains the source PoolGroup, and therefore the source ConnectionPoolKey. // The provider must be copied along with it, otherwise the clone would authenticate diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlClientAppTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlClientAppTests.cs index 6bdd7d4cf3..fd14c57fba 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlClientAppTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlClientAppTests.cs @@ -49,6 +49,7 @@ public void Default_Is_Unknown() [InlineData(SqlClientApp.AzureFunctionsSqlExtension, 0x0009)] [InlineData(SqlClientApp.OrleansAdoNet, 0x000A)] [InlineData(SqlClientApp.DurableTaskSqlServer, 0x000B)] + [InlineData(SqlClientApp.SqlPackage, 0x000C)] public void Members_Have_Stable_Values(SqlClientApp app, int expected) { Assert.Equal(expected, (int)app); @@ -122,4 +123,50 @@ public void SqlConnection_SqlClientAppId_RoundTrips() Assert.Equal(SqlClientApp.SemanticKernel, connection.SqlClientAppId); } + + /// + /// Verifies a cloned connection keeps the application identity of the + /// connection it was cloned from, so cloning does not silently drop the + /// identity back to . + /// + [Fact] + public void Clone_Preserves_SqlClientAppId() + { + using SqlConnection connection = new(); + connection.SqlClientAppId = SqlClientApp.SqlPackage; + + using SqlConnection clone = (SqlConnection)((ICloneable)connection).Clone(); + + Assert.Equal(SqlClientApp.SqlPackage, clone.SqlClientAppId); + } + + /// + /// Verifies the driver properties part reports the connection pool V2 flag + /// when, and only when, that implementation is enabled. + /// + [Theory] + [InlineData(false)] + [InlineData(true)] + public void DriverProperties_Reports_ConnectionPoolV2(bool useConnectionPoolV2) + { + SqlClientDriverProperties expected = useConnectionPoolV2 + ? SqlClientDriverProperties.ConnectionPoolV2 + : SqlClientDriverProperties.None; + + Assert.Equal(expected, SqlClientDriverPropertiesResolver.Resolve(useConnectionPoolV2)); + } + + /// + /// Verifies the flags reported for this process agree with the switch they + /// are derived from, so + /// cannot drift from the mapping it delegates to. + /// + [Fact] + public void DriverProperties_Current_Matches_Switch() + { + SqlClientDriverProperties expected = + SqlClientDriverPropertiesResolver.Resolve(LocalAppContextSwitches.UseConnectionPoolV2); + + Assert.Equal(expected, SqlClientDriverPropertiesResolver.Current); + } } From d849cf453bdda7dba1962c5113d5c4b5b83aa859 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Thu, 3 Sep 2026 15:22:28 -0700 Subject: [PATCH 10/12] Reject identity changes after login, propagate to discovery login - SqlClientAppId now rejects being set once the connection is connecting or open, matching AccessToken and the other login-time properties. The identity is only reported during login, so allowing a later change let the getter report a value that was never sent. - Propagate the identity to the preliminary SQL Express connection used to discover the user instance name. That connection performs its own physical login, which was reported as Unknown. - Add SQL_InvalidSqlClientAppId to the localized resource files, which are otherwise key-synchronized with the neutral file. The value is the neutral text pending localization. - Drop two comments describing the removed process-wide agent model. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: efbed43f-1014-45d7-a9f6-9f04711c281e --- .../Microsoft/Data/SqlClient/SqlConnection.cs | 8 +++++ .../Data/SqlClient/SqlConnectionFactory.cs | 3 +- .../src/Microsoft/Data/SqlClient/TdsParser.cs | 3 +- .../src/Microsoft/Data/SqlClient/UserAgent.cs | 2 +- .../src/Resources/Strings.cs.resx | 3 ++ .../src/Resources/Strings.de.resx | 3 ++ .../src/Resources/Strings.es.resx | 3 ++ .../src/Resources/Strings.fr.resx | 3 ++ .../src/Resources/Strings.it.resx | 3 ++ .../src/Resources/Strings.ja.resx | 3 ++ .../src/Resources/Strings.ko.resx | 3 ++ .../src/Resources/Strings.pl.resx | 3 ++ .../src/Resources/Strings.pt-BR.resx | 3 ++ .../src/Resources/Strings.ru.resx | 3 ++ .../src/Resources/Strings.tr.resx | 3 ++ .../src/Resources/Strings.zh-Hans.resx | 3 ++ .../src/Resources/Strings.zh-Hant.resx | 3 ++ .../SimulatedServerTests/ConnectionTests.cs | 29 +++++++++++++++++++ 18 files changed, 80 insertions(+), 4 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs index a6b6700b6d..a8a41bdcd6 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs @@ -389,6 +389,14 @@ public SqlClientApp SqlClientAppId get => _sqlClientAppId; set { + // The identity is only reported while logging in, so allowing it + // to change afterwards would let the getter report a value that + // was never sent. + if (!InnerConnection.AllowSetConnectionString) + { + throw ADP.OpenConnectionPropertySet(nameof(SqlClientAppId), InnerConnection.State); + } + // Identifiers are carried in 16 bits, so anything outside that // range cannot be reported and is rejected here rather than // being silently truncated at login. diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionFactory.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionFactory.cs index cd936c4f44..4bb1abd3b4 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionFactory.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionFactory.cs @@ -712,7 +712,8 @@ protected virtual DbConnectionInternal CreateConnection( redirectedUserInstance: false, applyTransientFaultHandling: applyTransientFaultHandling, sspiContextProvider: key.SspiContextProvider, - metrics: Metrics); + metrics: Metrics, + sqlClientAppId: sqlOwningConnection?.SqlClientAppId ?? SqlClientApp.Unknown); using (sseConnection) { // NOTE: Retrieve here. This user instance name will be diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs index 7800154410..7c079cfee0 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs @@ -1357,8 +1357,7 @@ internal void TdsLogin( int feOffset = length; // Capture the payload once so the length reserved below and the - // bytes written by WriteLoginData can never disagree, even if an - // agent is registered concurrently. + // bytes written by WriteLoginData can never disagree. ReadOnlyMemory userAgent = UserAgent.GetUcs2Bytes(rec.appId); // calculate and reserve the required bytes for the featureEx length = ApplyFeatureExData( diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent.cs index 82aeb66b9e..7d338a1bde 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent.cs @@ -526,7 +526,7 @@ internal static string Truncate(string value, ushort maxLength) // Our payload format version. // - // Version 2 adds the optional Agent Id part. + // Version 2 adds the App Id and Driver Properties parts. private const string PayloadVersion = "2"; // Our well-known .NET driver name. diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.cs.resx b/src/Microsoft.Data.SqlClient/src/Resources/Strings.cs.resx index f85eda7d30..d726c7cd48 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.cs.resx +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.cs.resx @@ -2199,6 +2199,9 @@ Nelze přejít ze stavu {0} do stavu {1}. + + The SqlClient application identifier '{0}' is outside the supported range of 0 to 65535. + Nepodporovaný stav: {0} diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.de.resx b/src/Microsoft.Data.SqlClient/src/Resources/Strings.de.resx index 7dcd133277..cb433cdb7e 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.de.resx +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.de.resx @@ -2199,6 +2199,9 @@ Ein Übergang vom Zustand „{0}“ zu „{1}“ ist nicht möglich. + + The SqlClient application identifier '{0}' is outside the supported range of 0 to 65535. + Nicht unterstützter Status: „{0}“. diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.es.resx b/src/Microsoft.Data.SqlClient/src/Resources/Strings.es.resx index 3471a75894..a9e03d5933 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.es.resx +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.es.resx @@ -2199,6 +2199,9 @@ No se puede realizar la transición del estado ''{0}'' a ''{1}''. + + The SqlClient application identifier '{0}' is outside the supported range of 0 to 65535. + Estado no admitido: ''{0}''. diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.fr.resx b/src/Microsoft.Data.SqlClient/src/Resources/Strings.fr.resx index d73673efbb..408c639ec0 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.fr.resx +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.fr.resx @@ -2199,6 +2199,9 @@ Impossible de passer de l'état « {0} » à l'état « {1} ». + + The SqlClient application identifier '{0}' is outside the supported range of 0 to 65535. + État non pris en charge : '{0}'. diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.it.resx b/src/Microsoft.Data.SqlClient/src/Resources/Strings.it.resx index 1e2e6764d5..e333f12a4e 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.it.resx +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.it.resx @@ -2199,6 +2199,9 @@ Non è possibile passare dallo stato '{0}' allo stato '{1}'. + + The SqlClient application identifier '{0}' is outside the supported range of 0 to 65535. + Stato non supportato: '{0}'. diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.ja.resx b/src/Microsoft.Data.SqlClient/src/Resources/Strings.ja.resx index 6e762be388..75781bfcf2 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.ja.resx +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.ja.resx @@ -2199,6 +2199,9 @@ 状態 '{0}' から '{1}' へ移行できません。 + + The SqlClient application identifier '{0}' is outside the supported range of 0 to 65535. + サポートされていない状態: '{0}'。 diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.ko.resx b/src/Microsoft.Data.SqlClient/src/Resources/Strings.ko.resx index ccca396df1..33c4cce1dc 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.ko.resx +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.ko.resx @@ -2199,6 +2199,9 @@ 상태 '{0}'에서 '{1}'(으)로 전환할 수 없습니다. + + The SqlClient application identifier '{0}' is outside the supported range of 0 to 65535. + 지원되지 않는 상태: '{0}'. diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.pl.resx b/src/Microsoft.Data.SqlClient/src/Resources/Strings.pl.resx index 9a2f4e6f96..cbdb5842da 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.pl.resx +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.pl.resx @@ -2199,6 +2199,9 @@ Nie można przejść ze stanu „{0}” na „{1}”. + + The SqlClient application identifier '{0}' is outside the supported range of 0 to 65535. + Nieobsługiwany stan: „{0}”. diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.pt-BR.resx b/src/Microsoft.Data.SqlClient/src/Resources/Strings.pt-BR.resx index 2e7593f6fd..d32f7e7e26 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.pt-BR.resx +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.pt-BR.resx @@ -2199,6 +2199,9 @@ Não é possível fazer a transição do estado '{0}' para '{1}'. + + The SqlClient application identifier '{0}' is outside the supported range of 0 to 65535. + Estado sem suporte: '{0}'. diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.ru.resx b/src/Microsoft.Data.SqlClient/src/Resources/Strings.ru.resx index 17f0d5ba7e..b2041447e9 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.ru.resx +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.ru.resx @@ -2199,6 +2199,9 @@ Не удается перейти из состояния "{0}" в "{1}". + + The SqlClient application identifier '{0}' is outside the supported range of 0 to 65535. + Неподдерживаемое состояние: "{0}". diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.tr.resx b/src/Microsoft.Data.SqlClient/src/Resources/Strings.tr.resx index f874f78401..9b3e19727c 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.tr.resx +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.tr.resx @@ -2199,6 +2199,9 @@ ‘{0}’ durumundan ‘{1}’ durumuna geçilemiyor. + + The SqlClient application identifier '{0}' is outside the supported range of 0 to 65535. + Desteklenmeyen durum: ‘{0}’. diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.zh-Hans.resx b/src/Microsoft.Data.SqlClient/src/Resources/Strings.zh-Hans.resx index 5a5ebe15a6..65a396524b 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.zh-Hans.resx +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.zh-Hans.resx @@ -2199,6 +2199,9 @@ 无法从状态 ‘{0}’ 转换为 ‘{1}’。 + + The SqlClient application identifier '{0}' is outside the supported range of 0 to 65535. + 不支持的状态: ‘{0}’。 diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.zh-Hant.resx b/src/Microsoft.Data.SqlClient/src/Resources/Strings.zh-Hant.resx index 9b0d8fd673..cddc482a40 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.zh-Hant.resx +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.zh-Hant.resx @@ -2199,6 +2199,9 @@ 無法從狀態 '{0}' 轉換到 '{1}'。 + + The SqlClient application identifier '{0}' is outside the supported range of 0 to 65535. + 不支援的狀態: '{0}'。 diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionTests.cs index f1d0549392..161dc6ae13 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionTests.cs @@ -1244,5 +1244,34 @@ public void TestConnWithUserAgentFeatureExtension(bool sendAck) // TODO: Confirm the server sent an Ack by reading log message from SqlInternalConnectionTds } + + /// + /// Verifies the application identity cannot be changed once the connection is open, since + /// it is only reported during login and the getter would otherwise report a value that was + /// never sent. + /// + [Fact] + public void SqlClientAppId_CannotBeSet_WhenConnectionIsOpen() + { + using TdsServer server = new(); + server.Start(); + + var connStr = new SqlConnectionStringBuilder + { + DataSource = $"localhost,{server.EndPoint.Port}", + Encrypt = SqlConnectionEncryptOption.Optional, + Pooling = false, + }.ConnectionString; + + using var connection = new SqlConnection(connStr); + connection.SqlClientAppId = SqlClientApp.EntityFramework; + connection.Open(); + + Assert.Throws( + () => connection.SqlClientAppId = SqlClientApp.SemanticKernel); + + // The connection still reports the identity it logged in with. + Assert.Equal(SqlClientApp.EntityFramework, connection.SqlClientAppId); + } } } From 095d84e87bb61d713fa93bdfa02f3d0b87d64fe2 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Thu, 3 Sep 2026 15:29:54 -0700 Subject: [PATCH 11/12] Document identity exception and trust boundary Record the InvalidOperationException the setter raises once the connection is opening or open, and state that the identity is client-supplied telemetry rather than an authenticated identity, so it must not be used for authorization. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: efbed43f-1014-45d7-a9f6-9f04711c281e --- doc/snippets/Microsoft.Data.SqlClient/SqlConnection.xml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/doc/snippets/Microsoft.Data.SqlClient/SqlConnection.xml b/doc/snippets/Microsoft.Data.SqlClient/SqlConnection.xml index 15f1ad233f..7f91f099d4 100644 --- a/doc/snippets/Microsoft.Data.SqlClient/SqlConnection.xml +++ b/doc/snippets/Microsoft.Data.SqlClient/SqlConnection.xml @@ -2317,12 +2317,20 @@ The following sample tries to open a connection to an invalid database to simula The value is outside the range 0 to 65535. + + The connection is opening or open. The identity is reported during login, so it must be set beforehand. + This API is intended for registered applications that reserve an identifier in . An unregistered identifier may be reported by casting a value to that type, provided it is within the 16-bit range the protocol allows. + + This value is telemetry. It is supplied entirely by the client, which may report any identifier in range, + so it is not an authenticated identity and must not be used for authorization or any other security + decision. + The identity is sent once, during login, so it must be set before the connection is opened. From e8a5509f56de65d6ee923ef76e24747ea8bae100 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Thu, 3 Sep 2026 17:42:01 -0700 Subject: [PATCH 12/12] Leave localized resx files to the OneLocBuild pipeline New resource strings are added only to the neutral Strings.resx; the scheduled OneLocBuild run populates the satellite files. Adding them by hand risks conflicting with that pipeline. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: efbed43f-1014-45d7-a9f6-9f04711c281e --- src/Microsoft.Data.SqlClient/src/Resources/Strings.cs.resx | 3 --- src/Microsoft.Data.SqlClient/src/Resources/Strings.de.resx | 3 --- src/Microsoft.Data.SqlClient/src/Resources/Strings.es.resx | 3 --- src/Microsoft.Data.SqlClient/src/Resources/Strings.fr.resx | 3 --- src/Microsoft.Data.SqlClient/src/Resources/Strings.it.resx | 3 --- src/Microsoft.Data.SqlClient/src/Resources/Strings.ja.resx | 3 --- src/Microsoft.Data.SqlClient/src/Resources/Strings.ko.resx | 3 --- src/Microsoft.Data.SqlClient/src/Resources/Strings.pl.resx | 3 --- src/Microsoft.Data.SqlClient/src/Resources/Strings.pt-BR.resx | 3 --- src/Microsoft.Data.SqlClient/src/Resources/Strings.ru.resx | 3 --- src/Microsoft.Data.SqlClient/src/Resources/Strings.tr.resx | 3 --- .../src/Resources/Strings.zh-Hans.resx | 3 --- .../src/Resources/Strings.zh-Hant.resx | 3 --- 13 files changed, 39 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.cs.resx b/src/Microsoft.Data.SqlClient/src/Resources/Strings.cs.resx index d726c7cd48..f85eda7d30 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.cs.resx +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.cs.resx @@ -2199,9 +2199,6 @@ Nelze přejít ze stavu {0} do stavu {1}. - - The SqlClient application identifier '{0}' is outside the supported range of 0 to 65535. - Nepodporovaný stav: {0} diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.de.resx b/src/Microsoft.Data.SqlClient/src/Resources/Strings.de.resx index cb433cdb7e..7dcd133277 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.de.resx +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.de.resx @@ -2199,9 +2199,6 @@ Ein Übergang vom Zustand „{0}“ zu „{1}“ ist nicht möglich. - - The SqlClient application identifier '{0}' is outside the supported range of 0 to 65535. - Nicht unterstützter Status: „{0}“. diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.es.resx b/src/Microsoft.Data.SqlClient/src/Resources/Strings.es.resx index a9e03d5933..3471a75894 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.es.resx +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.es.resx @@ -2199,9 +2199,6 @@ No se puede realizar la transición del estado ''{0}'' a ''{1}''. - - The SqlClient application identifier '{0}' is outside the supported range of 0 to 65535. - Estado no admitido: ''{0}''. diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.fr.resx b/src/Microsoft.Data.SqlClient/src/Resources/Strings.fr.resx index 408c639ec0..d73673efbb 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.fr.resx +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.fr.resx @@ -2199,9 +2199,6 @@ Impossible de passer de l'état « {0} » à l'état « {1} ». - - The SqlClient application identifier '{0}' is outside the supported range of 0 to 65535. - État non pris en charge : '{0}'. diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.it.resx b/src/Microsoft.Data.SqlClient/src/Resources/Strings.it.resx index e333f12a4e..1e2e6764d5 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.it.resx +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.it.resx @@ -2199,9 +2199,6 @@ Non è possibile passare dallo stato '{0}' allo stato '{1}'. - - The SqlClient application identifier '{0}' is outside the supported range of 0 to 65535. - Stato non supportato: '{0}'. diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.ja.resx b/src/Microsoft.Data.SqlClient/src/Resources/Strings.ja.resx index 75781bfcf2..6e762be388 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.ja.resx +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.ja.resx @@ -2199,9 +2199,6 @@ 状態 '{0}' から '{1}' へ移行できません。 - - The SqlClient application identifier '{0}' is outside the supported range of 0 to 65535. - サポートされていない状態: '{0}'。 diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.ko.resx b/src/Microsoft.Data.SqlClient/src/Resources/Strings.ko.resx index 33c4cce1dc..ccca396df1 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.ko.resx +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.ko.resx @@ -2199,9 +2199,6 @@ 상태 '{0}'에서 '{1}'(으)로 전환할 수 없습니다. - - The SqlClient application identifier '{0}' is outside the supported range of 0 to 65535. - 지원되지 않는 상태: '{0}'. diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.pl.resx b/src/Microsoft.Data.SqlClient/src/Resources/Strings.pl.resx index cbdb5842da..9a2f4e6f96 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.pl.resx +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.pl.resx @@ -2199,9 +2199,6 @@ Nie można przejść ze stanu „{0}” na „{1}”. - - The SqlClient application identifier '{0}' is outside the supported range of 0 to 65535. - Nieobsługiwany stan: „{0}”. diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.pt-BR.resx b/src/Microsoft.Data.SqlClient/src/Resources/Strings.pt-BR.resx index d32f7e7e26..2e7593f6fd 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.pt-BR.resx +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.pt-BR.resx @@ -2199,9 +2199,6 @@ Não é possível fazer a transição do estado '{0}' para '{1}'. - - The SqlClient application identifier '{0}' is outside the supported range of 0 to 65535. - Estado sem suporte: '{0}'. diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.ru.resx b/src/Microsoft.Data.SqlClient/src/Resources/Strings.ru.resx index b2041447e9..17f0d5ba7e 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.ru.resx +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.ru.resx @@ -2199,9 +2199,6 @@ Не удается перейти из состояния "{0}" в "{1}". - - The SqlClient application identifier '{0}' is outside the supported range of 0 to 65535. - Неподдерживаемое состояние: "{0}". diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.tr.resx b/src/Microsoft.Data.SqlClient/src/Resources/Strings.tr.resx index 9b3e19727c..f874f78401 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.tr.resx +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.tr.resx @@ -2199,9 +2199,6 @@ ‘{0}’ durumundan ‘{1}’ durumuna geçilemiyor. - - The SqlClient application identifier '{0}' is outside the supported range of 0 to 65535. - Desteklenmeyen durum: ‘{0}’. diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.zh-Hans.resx b/src/Microsoft.Data.SqlClient/src/Resources/Strings.zh-Hans.resx index 65a396524b..5a5ebe15a6 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.zh-Hans.resx +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.zh-Hans.resx @@ -2199,9 +2199,6 @@ 无法从状态 ‘{0}’ 转换为 ‘{1}’。 - - The SqlClient application identifier '{0}' is outside the supported range of 0 to 65535. - 不支持的状态: ‘{0}’。 diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.zh-Hant.resx b/src/Microsoft.Data.SqlClient/src/Resources/Strings.zh-Hant.resx index cddc482a40..9b0d8fd673 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.zh-Hant.resx +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.zh-Hant.resx @@ -2199,9 +2199,6 @@ 無法從狀態 '{0}' 轉換到 '{1}'。 - - The SqlClient application identifier '{0}' is outside the supported range of 0 to 65535. - 不支援的狀態: '{0}'。