diff --git a/.gitattributes b/.gitattributes
index dfe077042..31bb7740b 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,2 +1,3 @@
# Auto detect text files and perform LF normalization
* text=auto
+*.sh text eol=lf
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c920c71d0..d5d0620a5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -51,7 +51,7 @@ jobs:
creds: ${{ secrets.AZURE_ACI_CREDENTIALS }}
enable-AzPSSession: true
- name: Setup RabbitMQ
- uses: Particular/setup-rabbitmq-action@v1.6.0
+ uses: Particular/setup-rabbitmq-action@v1.7.0
with:
connection-string-name: RabbitMQTransport_ConnectionString
tag: RabbitMQTransport
diff --git a/src/.editorconfig b/src/.editorconfig
index f24a83fba..6a0594940 100644
--- a/src/.editorconfig
+++ b/src/.editorconfig
@@ -4,6 +4,7 @@
[*.{csproj,props,targets,xml}]
indent_style = space
indent_size = 2
+xml_space_inside_empty_tag = true
[*.cs]
@@ -317,6 +318,10 @@ dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = error
dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
+dotnet_naming_rule.fields.style = camel_case
+dotnet_naming_rule.fields.symbols = fields
+dotnet_naming_rule.fields.severity = none
+
# Symbol specifications
dotnet_naming_symbols.interface.applicable_kinds = interface
dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
@@ -330,6 +335,10 @@ dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, meth
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.non_field_members.required_modifiers =
+dotnet_naming_symbols.fields.applicable_kinds = field
+dotnet_naming_symbols.fields.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.fields.required_modifiers =
+
# Naming styles
dotnet_naming_style.pascal_case.required_prefix =
dotnet_naming_style.pascal_case.required_suffix =
@@ -340,3 +349,8 @@ dotnet_naming_style.begins_with_i.required_prefix = I
dotnet_naming_style.begins_with_i.required_suffix =
dotnet_naming_style.begins_with_i.word_separator =
dotnet_naming_style.begins_with_i.capitalization = pascal_case
+
+dotnet_naming_style.camel_case.required_prefix =
+dotnet_naming_style.camel_case.required_suffix =
+dotnet_naming_style.camel_case.word_separator =
+dotnet_naming_style.camel_case.capitalization = camel_case
diff --git a/src/Custom.Build.props b/src/Custom.Build.props
index dc95a5770..297088dd9 100644
--- a/src/Custom.Build.props
+++ b/src/Custom.Build.props
@@ -1,8 +1,8 @@
- 9.0
- minor
+ 9.1
+ patch
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index 7e3a698c3..4b7369cc5 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -7,10 +7,10 @@
true
5.0
true
-
low
+ all
- 2.1.2
+ 2.1.3
0024000004800000940000000602000000240000525341310004000001000100dde965e6172e019ac82c2639ffe494dd2e7dd16347c34762a05732b492e110f2e4e2e1b5ef2d85c848ccfb671ee20a47c8d1376276708dc30a90ff1121b647ba3b7259a6bc383b2034938ef0e275b58b920375ac605076178123693c6c4f1331661a62eba28c249386855637780e3ff5f23a6d854700eaa6803ef48907513b92
00240000048000009400000006020000002400005253413100040000010001007f16e21368ff041183fab592d9e8ed37e7be355e93323147a1d29983d6e591b04282e4da0c9e18bd901e112c0033925eb7d7872c2f1706655891c5c9d57297994f707d16ee9a8f40d978f064ee1ffc73c0db3f4712691b23bf596f75130f4ec978cf78757ec034625a5f27e6bb50c618931ea49f6f628fd74271c32959efb1c5
diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets
index d47a6af61..98966408a 100644
--- a/src/Directory.Build.targets
+++ b/src/Directory.Build.targets
@@ -1,7 +1,5 @@
-
- all
-
+
diff --git a/src/NServiceBus.Transport.RabbitMQ.AcceptanceTests/NServiceBus.Transport.RabbitMQ.AcceptanceTests.csproj b/src/NServiceBus.Transport.RabbitMQ.AcceptanceTests/NServiceBus.Transport.RabbitMQ.AcceptanceTests.csproj
index fb9018de4..2b00b6bc4 100644
--- a/src/NServiceBus.Transport.RabbitMQ.AcceptanceTests/NServiceBus.Transport.RabbitMQ.AcceptanceTests.csproj
+++ b/src/NServiceBus.Transport.RabbitMQ.AcceptanceTests/NServiceBus.Transport.RabbitMQ.AcceptanceTests.csproj
@@ -12,15 +12,13 @@
-
+
-
-
-
+
diff --git a/src/NServiceBus.Transport.RabbitMQ.CommandLine.Tests/NServiceBus.Transport.RabbitMQ.CommandLine.Tests.csproj b/src/NServiceBus.Transport.RabbitMQ.CommandLine.Tests/NServiceBus.Transport.RabbitMQ.CommandLine.Tests.csproj
index ac31dc3e9..eb835463e 100644
--- a/src/NServiceBus.Transport.RabbitMQ.CommandLine.Tests/NServiceBus.Transport.RabbitMQ.CommandLine.Tests.csproj
+++ b/src/NServiceBus.Transport.RabbitMQ.CommandLine.Tests/NServiceBus.Transport.RabbitMQ.CommandLine.Tests.csproj
@@ -12,7 +12,7 @@
-
+
diff --git a/src/NServiceBus.Transport.RabbitMQ.CommandLine/NServiceBus.Transport.RabbitMQ.CommandLine.csproj b/src/NServiceBus.Transport.RabbitMQ.CommandLine/NServiceBus.Transport.RabbitMQ.CommandLine.csproj
index 098f4da19..00d106457 100644
--- a/src/NServiceBus.Transport.RabbitMQ.CommandLine/NServiceBus.Transport.RabbitMQ.CommandLine.csproj
+++ b/src/NServiceBus.Transport.RabbitMQ.CommandLine/NServiceBus.Transport.RabbitMQ.CommandLine.csproj
@@ -15,13 +15,13 @@
-
+
-
+
diff --git a/src/NServiceBus.Transport.RabbitMQ.Tests/Connection/ChannelProviderTests.cs b/src/NServiceBus.Transport.RabbitMQ.Tests/Connection/ChannelProviderTests.cs
new file mode 100644
index 000000000..cac893b3d
--- /dev/null
+++ b/src/NServiceBus.Transport.RabbitMQ.Tests/Connection/ChannelProviderTests.cs
@@ -0,0 +1,168 @@
+namespace NServiceBus.Transport.RabbitMQ.Tests.ConnectionString
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using global::RabbitMQ.Client;
+ using global::RabbitMQ.Client.Events;
+ using NUnit.Framework;
+
+ [TestFixture]
+ public class ChannelProviderTests
+ {
+ [Test]
+ public async Task Should_recover_connection_and_dispose_old_one_when_connection_shutdown()
+ {
+ var channelProvider = new TestableChannelProvider();
+ channelProvider.CreateConnection();
+
+ var publishConnection = channelProvider.PublishConnections.Dequeue();
+ publishConnection.RaiseConnectionShutdown(new ShutdownEventArgs(ShutdownInitiator.Library, 0, "Test"));
+
+ channelProvider.DelayTaskCompletionSource.SetResult();
+
+ await channelProvider.FireAndForgetAction(CancellationToken.None);
+
+ var recoveredConnection = channelProvider.PublishConnections.Dequeue();
+
+ Assert.That(publishConnection.WasDisposed, Is.True);
+ Assert.That(recoveredConnection.WasDisposed, Is.False);
+ }
+
+ [Test]
+ public void Should_dispose_connection_when_disposed()
+ {
+ var channelProvider = new TestableChannelProvider();
+ channelProvider.CreateConnection();
+
+ var publishConnection = channelProvider.PublishConnections.Dequeue();
+ channelProvider.Dispose();
+
+ Assert.That(publishConnection.WasDisposed, Is.True);
+ }
+
+ [Test]
+ public async Task Should_not_attempt_to_recover_during_dispose_when_retry_delay_still_pending()
+ {
+ var channelProvider = new TestableChannelProvider();
+ channelProvider.CreateConnection();
+
+ var publishConnection = channelProvider.PublishConnections.Dequeue();
+ publishConnection.RaiseConnectionShutdown(new ShutdownEventArgs(ShutdownInitiator.Library, 0, "Test"));
+
+ // Deliberately not completing the delay task with channelProvider.DelayTaskCompletionSource.SetResult(); before disposing
+ // to simulate a pending delay task
+ channelProvider.Dispose();
+
+ await channelProvider.FireAndForgetAction(CancellationToken.None);
+
+ Assert.That(publishConnection.WasDisposed, Is.True);
+ Assert.That(channelProvider.PublishConnections.TryDequeue(out _), Is.False);
+ }
+
+ [Test]
+ public async Task Should_dispose_newly_established_connection()
+ {
+ var channelProvider = new TestableChannelProvider();
+ channelProvider.CreateConnection();
+
+ var publishConnection = channelProvider.PublishConnections.Dequeue();
+ publishConnection.RaiseConnectionShutdown(new ShutdownEventArgs(ShutdownInitiator.Library, 0, "Test"));
+
+ // This simulates the race of the reconnection loop being fired off with the delay task completed during
+ // the disposal of the channel provider. To achieve that it is necessary to kick off the reconnection loop
+ // and await its completion after the channel provider has been disposed.
+ var fireAndForgetTask = channelProvider.FireAndForgetAction(CancellationToken.None);
+ channelProvider.DelayTaskCompletionSource.SetResult();
+ channelProvider.Dispose();
+
+ await fireAndForgetTask;
+
+ var recoveredConnection = channelProvider.PublishConnections.Dequeue();
+
+ Assert.That(publishConnection.WasDisposed, Is.True);
+ Assert.That(recoveredConnection.WasDisposed, Is.True);
+ }
+
+ class TestableChannelProvider() : ChannelProvider(null!, TimeSpan.Zero, null!)
+ {
+ public Queue PublishConnections { get; } = new();
+
+ public TaskCompletionSource DelayTaskCompletionSource { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ public Func FireAndForgetAction { get; private set; }
+
+ protected override IConnection CreatePublishConnection()
+ {
+ var connection = new FakeConnection();
+ PublishConnections.Enqueue(connection);
+ return connection;
+ }
+
+ protected override void FireAndForget(Func action, CancellationToken cancellationToken = default)
+ => FireAndForgetAction = _ => action(cancellationToken);
+
+ protected override async Task DelayReconnect(CancellationToken cancellationToken = default)
+ {
+ await using var _ = cancellationToken.Register(() => DelayTaskCompletionSource.TrySetCanceled(cancellationToken));
+ await DelayTaskCompletionSource.Task;
+ }
+ }
+
+ class FakeConnection : IConnection
+ {
+ public int LocalPort { get; }
+ public int RemotePort { get; }
+
+ public void Dispose() => WasDisposed = true;
+
+ public bool WasDisposed { get; private set; }
+
+ public void UpdateSecret(string newSecret, string reason) => throw new NotImplementedException();
+
+ public void Abort() => throw new NotImplementedException();
+
+ public void Abort(ushort reasonCode, string reasonText) => throw new NotImplementedException();
+
+ public void Abort(TimeSpan timeout) => throw new NotImplementedException();
+
+ public void Abort(ushort reasonCode, string reasonText, TimeSpan timeout) => throw new NotImplementedException();
+
+ public void Close() => throw new NotImplementedException();
+
+ public void Close(ushort reasonCode, string reasonText) => throw new NotImplementedException();
+
+ public void Close(TimeSpan timeout) => throw new NotImplementedException();
+
+ public void Close(ushort reasonCode, string reasonText, TimeSpan timeout) => throw new NotImplementedException();
+
+ public IModel CreateModel() => throw new NotImplementedException();
+
+ public void HandleConnectionBlocked(string reason) => throw new NotImplementedException();
+
+ public void HandleConnectionUnblocked() => throw new NotImplementedException();
+
+ public ushort ChannelMax { get; }
+ public IDictionary ClientProperties { get; }
+ public ShutdownEventArgs CloseReason { get; }
+ public AmqpTcpEndpoint Endpoint { get; }
+ public uint FrameMax { get; }
+ public TimeSpan Heartbeat { get; }
+ public bool IsOpen { get; }
+ public AmqpTcpEndpoint[] KnownHosts { get; }
+ public IProtocol Protocol { get; }
+ public IDictionary ServerProperties { get; }
+ public IList ShutdownReport { get; }
+ public string ClientProvidedName { get; } = $"FakeConnection{Interlocked.Increment(ref connectionCounter)}";
+ public event EventHandler CallbackException = (_, _) => { };
+ public event EventHandler ConnectionBlocked = (_, _) => { };
+ public event EventHandler ConnectionShutdown = (_, _) => { };
+ public event EventHandler ConnectionUnblocked = (_, _) => { };
+
+ public void RaiseConnectionShutdown(ShutdownEventArgs args) => ConnectionShutdown?.Invoke(this, args);
+
+ static int connectionCounter;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/NServiceBus.Transport.RabbitMQ.Tests/ConnectionString/ConnectionConfigurationTests.cs b/src/NServiceBus.Transport.RabbitMQ.Tests/Connection/ConnectionConfigurationTests.cs
similarity index 100%
rename from src/NServiceBus.Transport.RabbitMQ.Tests/ConnectionString/ConnectionConfigurationTests.cs
rename to src/NServiceBus.Transport.RabbitMQ.Tests/Connection/ConnectionConfigurationTests.cs
diff --git a/src/NServiceBus.Transport.RabbitMQ.Tests/ConnectionString/ConnectionConfigurationWithAmqpTests.cs b/src/NServiceBus.Transport.RabbitMQ.Tests/Connection/ConnectionConfigurationWithAmqpTests.cs
similarity index 100%
rename from src/NServiceBus.Transport.RabbitMQ.Tests/ConnectionString/ConnectionConfigurationWithAmqpTests.cs
rename to src/NServiceBus.Transport.RabbitMQ.Tests/Connection/ConnectionConfigurationWithAmqpTests.cs
diff --git a/src/NServiceBus.Transport.RabbitMQ.Tests/NServiceBus.Transport.RabbitMQ.Tests.csproj b/src/NServiceBus.Transport.RabbitMQ.Tests/NServiceBus.Transport.RabbitMQ.Tests.csproj
index 4c27357a6..69741b108 100644
--- a/src/NServiceBus.Transport.RabbitMQ.Tests/NServiceBus.Transport.RabbitMQ.Tests.csproj
+++ b/src/NServiceBus.Transport.RabbitMQ.Tests/NServiceBus.Transport.RabbitMQ.Tests.csproj
@@ -12,17 +12,14 @@
-
+
-
-
-
+
-
diff --git a/src/NServiceBus.Transport.RabbitMQ.TransportTests/NServiceBus.Transport.RabbitMQ.TransportTests.csproj b/src/NServiceBus.Transport.RabbitMQ.TransportTests/NServiceBus.Transport.RabbitMQ.TransportTests.csproj
index b34c8a1b7..c5aec8579 100644
--- a/src/NServiceBus.Transport.RabbitMQ.TransportTests/NServiceBus.Transport.RabbitMQ.TransportTests.csproj
+++ b/src/NServiceBus.Transport.RabbitMQ.TransportTests/NServiceBus.Transport.RabbitMQ.TransportTests.csproj
@@ -10,15 +10,13 @@
-
+
-
-
-
+
diff --git a/src/NServiceBus.Transport.RabbitMQ.TransportTests/When_changing_concurrency.cs b/src/NServiceBus.Transport.RabbitMQ.TransportTests/When_changing_concurrency.cs
index 665dedfd7..75ab33744 100644
--- a/src/NServiceBus.Transport.RabbitMQ.TransportTests/When_changing_concurrency.cs
+++ b/src/NServiceBus.Transport.RabbitMQ.TransportTests/When_changing_concurrency.cs
@@ -16,6 +16,7 @@ public class When_changing_concurrency : NServiceBusTransportTest
public async Task Should_complete_current_message(TransportTransactionMode transactionMode)
{
var triggeredChangeConcurrency = CreateTaskCompletionSource();
+ var sentMessageReceived = CreateTaskCompletionSource();
Task concurrencyChanged = null;
int invocationCounter = 0;
@@ -30,6 +31,7 @@ await StartPump(async (context, ct) =>
await task;
}, ct);
+ sentMessageReceived.SetResult();
await triggeredChangeConcurrency.Task;
}, (_, _) =>
@@ -40,8 +42,10 @@ await StartPump(async (context, ct) =>
transactionMode);
await SendMessage(InputQueueName);
+ await sentMessageReceived.Task;
await concurrencyChanged;
await StopPump();
+
Assert.AreEqual(1, invocationCounter, "message should successfully complete on first processing attempt");
}
@@ -62,6 +66,7 @@ await StartPump((context, _) =>
if (context.Headers.TryGetValue("FromOnError", out var value) && value == bool.TrueString)
{
sentMessageReceived.SetResult();
+ return Task.CompletedTask;
}
throw new Exception("triggering recoverability pipeline");
@@ -84,9 +89,9 @@ await SendMessage(InputQueueName,
transactionMode);
await SendMessage(InputQueueName);
-
await sentMessageReceived.Task;
await StopPump();
+
Assert.AreEqual(2, invocationCounter, "there should be exactly 2 messages (initial message and new message from onError pipeline)");
}
}
diff --git a/src/NServiceBus.Transport.RabbitMQ/Connection/ChannelProvider.cs b/src/NServiceBus.Transport.RabbitMQ/Connection/ChannelProvider.cs
index 01419652c..afd572a0f 100644
--- a/src/NServiceBus.Transport.RabbitMQ/Connection/ChannelProvider.cs
+++ b/src/NServiceBus.Transport.RabbitMQ/Connection/ChannelProvider.cs
@@ -1,3 +1,5 @@
+#nullable enable
+
namespace NServiceBus.Transport.RabbitMQ
{
using System;
@@ -7,7 +9,7 @@ namespace NServiceBus.Transport.RabbitMQ
using global::RabbitMQ.Client;
using Logging;
- sealed class ChannelProvider : IDisposable
+ class ChannelProvider : IDisposable
{
public ChannelProvider(ConnectionFactory connectionFactory, TimeSpan retryDelay, IRoutingTopology routingTopology)
{
@@ -19,36 +21,56 @@ public ChannelProvider(ConnectionFactory connectionFactory, TimeSpan retryDelay,
channels = new ConcurrentQueue();
}
- public void CreateConnection()
+ public void CreateConnection() => connection = CreateConnectionWithShutdownListener();
+
+ protected virtual IConnection CreatePublishConnection() => connectionFactory.CreatePublishConnection();
+
+ IConnection CreateConnectionWithShutdownListener()
{
- connection = connectionFactory.CreatePublishConnection();
- connection.ConnectionShutdown += Connection_ConnectionShutdown;
+ var newConnection = CreatePublishConnection();
+ newConnection.ConnectionShutdown += Connection_ConnectionShutdown;
+ return newConnection;
}
- void Connection_ConnectionShutdown(object sender, ShutdownEventArgs e)
+ void Connection_ConnectionShutdown(object? sender, ShutdownEventArgs e)
{
- if (e.Initiator != ShutdownInitiator.Application)
+ if (e.Initiator == ShutdownInitiator.Application || sender is null)
{
- var connection = (IConnection)sender;
-
- // Task.Run() so the call returns immediately instead of waiting for the first await or return down the call stack
- _ = Task.Run(() => ReconnectSwallowingExceptions(connection.ClientProvidedName), CancellationToken.None);
+ return;
}
+
+ var connectionThatWasShutdown = (IConnection)sender;
+
+ FireAndForget(cancellationToken => ReconnectSwallowingExceptions(connectionThatWasShutdown.ClientProvidedName, cancellationToken), stoppingTokenSource.Token);
}
-#pragma warning disable PS0018 // A task-returning method should have a CancellationToken parameter unless it has a parameter implementing ICancellableContext
- async Task ReconnectSwallowingExceptions(string connectionName)
-#pragma warning restore PS0018 // A task-returning method should have a CancellationToken parameter unless it has a parameter implementing ICancellableContext
+ async Task ReconnectSwallowingExceptions(string connectionName, CancellationToken cancellationToken)
{
- while (true)
+ while (!cancellationToken.IsCancellationRequested)
{
Logger.InfoFormat("'{0}': Attempting to reconnect in {1} seconds.", connectionName, retryDelay.TotalSeconds);
- await Task.Delay(retryDelay).ConfigureAwait(false);
-
try
{
- CreateConnection();
+ await DelayReconnect(cancellationToken).ConfigureAwait(false);
+
+ var newConnection = CreateConnectionWithShutdownListener();
+
+ // A race condition is possible where CreatePublishConnection is invoked during Dispose
+ // where the returned connection isn't disposed so invoking Dispose to be sure
+ if (cancellationToken.IsCancellationRequested)
+ {
+ newConnection.Dispose();
+ break;
+ }
+
+ var oldConnection = Interlocked.Exchange(ref connection, newConnection);
+ oldConnection?.Dispose();
+ break;
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ Logger.InfoFormat("'{0}': Stopped trying to reconnecting to the broker due to shutdown", connectionName);
break;
}
catch (Exception ex)
@@ -60,6 +82,12 @@ async Task ReconnectSwallowingExceptions(string connectionName)
Logger.InfoFormat("'{0}': Connection to the broker reestablished successfully.", connectionName);
}
+ protected virtual void FireAndForget(Func action, CancellationToken cancellationToken = default) =>
+ // Task.Run() so the call returns immediately instead of waiting for the first await or return down the call stack
+ _ = Task.Run(() => action(cancellationToken), CancellationToken.None);
+
+ protected virtual Task DelayReconnect(CancellationToken cancellationToken = default) => Task.Delay(retryDelay, cancellationToken);
+
public ConfirmsAwareChannel GetPublishChannel()
{
if (!channels.TryDequeue(out var channel) || channel.IsClosed)
@@ -86,19 +114,32 @@ public void ReturnPublishChannel(ConfirmsAwareChannel channel)
public void Dispose()
{
- connection?.Dispose();
+ if (disposed)
+ {
+ return;
+ }
+
+ stoppingTokenSource.Cancel();
+ stoppingTokenSource.Dispose();
+
+ var oldConnection = Interlocked.Exchange(ref connection, null);
+ oldConnection?.Dispose();
foreach (var channel in channels)
{
channel.Dispose();
}
+
+ disposed = true;
}
readonly ConnectionFactory connectionFactory;
readonly TimeSpan retryDelay;
readonly IRoutingTopology routingTopology;
readonly ConcurrentQueue channels;
- IConnection connection;
+ readonly CancellationTokenSource stoppingTokenSource = new();
+ volatile IConnection? connection;
+ bool disposed;
static readonly ILog Logger = LogManager.GetLogger(typeof(ChannelProvider));
}
diff --git a/src/NServiceBus.Transport.RabbitMQ/NServiceBus.Transport.RabbitMQ.csproj b/src/NServiceBus.Transport.RabbitMQ/NServiceBus.Transport.RabbitMQ.csproj
index 2d033b7c0..5580b5439 100644
--- a/src/NServiceBus.Transport.RabbitMQ/NServiceBus.Transport.RabbitMQ.csproj
+++ b/src/NServiceBus.Transport.RabbitMQ/NServiceBus.Transport.RabbitMQ.csproj
@@ -8,15 +8,15 @@
-
-
-
+
+
+
-
+
-
+
diff --git a/src/msbuild/AutomaticVersionRanges.targets b/src/msbuild/AutomaticVersionRanges.targets
new file mode 100644
index 000000000..973721032
--- /dev/null
+++ b/src/msbuild/AutomaticVersionRanges.targets
@@ -0,0 +1,42 @@
+
+
+
+ false
+ false
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+ @(_ProjectReferencesWithVersions->Count())
+
+
+
+
+
+ <_ProjectReferencesWithVersions Remove="@(_ProjectReferencesWithVersions)" />
+ <_ProjectReferencesWithVersions Include="@(_ProjectReferencesWithVersionRanges)" />
+
+
+
+
+
+ @(PackageReference->Count())
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/msbuild/ConvertToVersionRange.cs b/src/msbuild/ConvertToVersionRange.cs
new file mode 100644
index 000000000..d91847845
--- /dev/null
+++ b/src/msbuild/ConvertToVersionRange.cs
@@ -0,0 +1,57 @@
+using System;
+using System.Text.RegularExpressions;
+using Microsoft.Build.Framework;
+using Microsoft.Build.Utilities;
+
+public class ConvertToVersionRange : Task
+{
+ [Required]
+ public ITaskItem[] References { get; set; } = [];
+
+ [Required]
+ public string VersionProperty { get; set; } = string.Empty;
+
+ [Output]
+ public ITaskItem[] ReferencesWithVersionRanges { get; private set; } = [];
+
+ public override bool Execute()
+ {
+ var success = true;
+
+ foreach (var reference in References)
+ {
+ var automaticVersionRange = reference.GetMetadata("AutomaticVersionRange");
+
+ if (automaticVersionRange.Equals("false", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ var privateAssets = reference.GetMetadata("PrivateAssets");
+
+ if (privateAssets.Equals("All", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ var version = reference.GetMetadata(VersionProperty);
+ var match = Regex.Match(version, @"^\d+");
+
+ if (match.Value.Equals(string.Empty, StringComparison.Ordinal))
+ {
+ Log.LogError("Reference '{0}' with version '{1}' is not valid for automatic version range conversion. Fix the version or exclude the reference from conversion by setting 'AutomaticVersionRange=\"false\"' on the reference.", reference.ItemSpec, version);
+ success = false;
+ continue;
+ }
+
+ var nextMajor = Convert.ToInt32(match.Value) + 1;
+
+ var versionRange = $"[{version}, {nextMajor}.0.0)";
+ reference.SetMetadata(VersionProperty, versionRange);
+ }
+
+ ReferencesWithVersionRanges = References;
+
+ return success;
+ }
+}