From 3530d7375640343e427054623f6d46844ce0d811 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eirik=20B=C3=B8e?= Date: Sun, 20 Sep 2026 14:52:42 +0200 Subject: [PATCH 1/4] Change context management to ThreadStatic for safety Refactor context handling in CreateAndConfigure method to use ThreadStatic field for thread safety. --- .../VersionedOpenApiOptionsFactory.cs | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Configuration/VersionedOpenApiOptionsFactory.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Configuration/VersionedOpenApiOptionsFactory.cs index dfd36126..da7fcbbf 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Configuration/VersionedOpenApiOptionsFactory.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Configuration/VersionedOpenApiOptionsFactory.cs @@ -22,18 +22,30 @@ internal sealed class VersionedOpenApiOptionsFactory( private readonly IConfigureOptions[] setups = [.. setups]; private readonly IPostConfigureOptions[] postConfigures = [.. postConfigures]; private readonly IValidateOptions[] validations = [.. validations]; - private Context? context; + + // create is only ever invoked synchronously from CreateAndConfigure on the same thread. the factory is a singleton, so + // an instance field would be shared by concurrent requests for different documents and one would clear it under another + [ThreadStatic] + private static Context? current; internal VersionedOpenApiOptions CreateAndConfigure( Context newContext ) { - context = newContext; - var instance = Create( newContext.Name ); - context = default; - return instance; + current = newContext; + + try + { + return Create( newContext.Name ); + } + finally + { + current = default; + } } public VersionedOpenApiOptions Create( string name ) { + var context = current; + if ( string.IsNullOrEmpty( name ) || context is null ) { return DefaultOptions(); @@ -103,4 +115,4 @@ internal sealed class Context public required Action OnCreated { get; init; } } -} \ No newline at end of file +} From c9647d90a192bee40c1e5f4e9558e766c5b25bfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eirik=20B=C3=B8e?= Date: Sun, 20 Sep 2026 14:54:29 +0200 Subject: [PATCH 2/4] Update VersionedOpenApiOptionsFactory.cs From d50695b563da41a5e250fd69b3c7e7ac62ae8945 Mon Sep 17 00:00:00 2001 From: OptoCloud Date: Sun, 20 Sep 2026 14:57:03 +0200 Subject: [PATCH 3/4] Update VersionedOpenApiOptionsFactory.cs --- .../Configuration/VersionedOpenApiOptionsFactory.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Configuration/VersionedOpenApiOptionsFactory.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Configuration/VersionedOpenApiOptionsFactory.cs index da7fcbbf..d31f852d 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Configuration/VersionedOpenApiOptionsFactory.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Configuration/VersionedOpenApiOptionsFactory.cs @@ -115,4 +115,4 @@ internal sealed class Context public required Action OnCreated { get; init; } } -} +} \ No newline at end of file From dfcb69c25b3a916449ef5f52a351ef06b745ce6f Mon Sep 17 00:00:00 2001 From: OptoCloud Date: Sun, 20 Sep 2026 15:30:50 +0200 Subject: [PATCH 4/4] Create VersionedOpenApiOptionsFactoryTest.cs --- .../VersionedOpenApiOptionsFactoryTest.cs | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/VersionedOpenApiOptionsFactoryTest.cs diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/VersionedOpenApiOptionsFactoryTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/VersionedOpenApiOptionsFactoryTest.cs new file mode 100644 index 00000000..39bd9b90 --- /dev/null +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/VersionedOpenApiOptionsFactoryTest.cs @@ -0,0 +1,82 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.OpenApi; + +using Asp.Versioning.OpenApi.Simulators; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Json; +using System.Text.Json.Nodes; + +public class VersionedOpenApiOptionsFactoryTest +{ + // the options of a document are created the first time it is requested. creating the options of different + // documents at the same time used to corrupt each other, which depends on timing, so the host is started again + // and every document is requested at the same moment several times + private const int Attempts = 50; + + [Fact] + public async Task open_api_documents_requested_concurrently_should_all_be_configured() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + + for ( var attempt = 0; attempt < Attempts; attempt++ ) + { + var builder = WebApplication.CreateBuilder(); + + builder.WebHost.UseTestServer(); + builder.Services.AddApiVersioning() + .AddApiExplorer( options => options.GroupNameFormat = "'v'VVV" ) + .AddOpenApi(); + + IsolateMinimalApis( builder.Services ); + + await using var app = builder.Build(); + + app.NewVersionedApi( "One" ).MapGroup( "/one" ).HasApiVersion( 1.0 ).MapGet( "{id:int}", MinimalApi.Get ); + app.NewVersionedApi( "Two" ).MapGroup( "/two" ).HasApiVersion( 2.0 ).MapGet( "{id:int}", MinimalApi.Get ); + app.MapOpenApi().WithDocumentPerVersion(); + + await app.StartAsync( cancellationToken ); + + // act + var documents = await GetDocumentsAsync( app, cancellationToken ); + + // assert + AssertConfigured( documents[0], "1.0", "/one/{id}" ); + AssertConfigured( documents[1], "2.0", "/two/{id}" ); + } + } + + private static async Task GetDocumentsAsync( WebApplication app, CancellationToken cancellationToken ) + { + using var client = app.GetTestClient(); + var start = new TaskCompletionSource(); + + async Task GetAsync( string documentName ) + { + await start.Task; + return await client.GetFromJsonAsync( $"/openapi/{documentName}.json", cancellationToken ); + } + + var requests = Task.WhenAll( GetAsync( "v1" ), GetAsync( "v2" ) ); + + // release both requests together + start.SetResult(); + + return await requests; + } + + private static void AssertConfigured( JsonNode document, string version, string path ) + { + // a document that was created with the default options has neither the api version nor any of the api paths + document.Should().NotBeNull(); + document["info"]["version"].GetValue().Should().Be( version ); + document["paths"].AsObject().ContainsKey( path ).Should().BeTrue(); + } + + private static void IsolateMinimalApis( IServiceCollection services ) => + services.AddMvcCore().ConfigureApplicationPartManager( m => m.ApplicationParts.Clear() ); +} \ No newline at end of file