diff --git a/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Proxies/McpExecutorSession.cs b/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Proxies/McpExecutorSession.cs deleted file mode 100644 index 42a36b2712b..00000000000 --- a/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Proxies/McpExecutorSession.cs +++ /dev/null @@ -1,5 +0,0 @@ -using ModelContextProtocol.AspNetCore; - -namespace HotChocolate.Adapters.Mcp.Proxies; - -internal sealed record McpExecutorSession(StreamableHttpHandler StreamableHttpHandler); diff --git a/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Proxies/McpRequestExecutorProxy.cs b/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Proxies/McpRequestExecutorProxy.cs index 502ed885b38..6db7bc95f11 100644 --- a/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Proxies/McpRequestExecutorProxy.cs +++ b/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Proxies/McpRequestExecutorProxy.cs @@ -1,6 +1,5 @@ using System.Collections.Concurrent; using HotChocolate.Execution; -using HotChocolate.Features; using HotChocolate.Utilities; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.AspNetCore; @@ -15,42 +14,34 @@ internal sealed class McpRequestExecutorProxy( string schemaName) : RequestExecutorProxy(executorProvider, executorEvents, schemaName) { - private McpExecutorSession? _session; + private StreamableHttpHandler? _handler; - public async ValueTask GetOrCreateSessionAsync( + public async ValueTask GetStreamableHttpHandlerAsync( CancellationToken cancellationToken) { - if (_session is not null) + if (_handler is not null) { - return _session; + return _handler; } var executor = await GetExecutorAsync(cancellationToken).ConfigureAwait(false); - return executor.Features.GetRequired(); + + return executor.Schema.Services.GetRequiredService(); } protected override void OnConfigureRequestExecutor( IRequestExecutor newExecutor, IRequestExecutor? oldExecutor) { - if (oldExecutor is not null) - { - newExecutor.Features.Set( - oldExecutor.Schema.Services.GetRequiredService>()); - } - - var session = new McpExecutorSession(newExecutor.Schema.Services.GetRequiredService()); - - newExecutor.Features.Set(session); - _session = session; + _handler = newExecutor.Schema.Services.GetRequiredService(); } protected override void OnAfterRequestExecutorSwapped( IRequestExecutor newExecutor, IRequestExecutor oldExecutor) { - var mcpServers = - newExecutor.Features.GetRequired>(); + var mcpServers = oldExecutor.Schema.Services + .GetRequiredService>(); foreach (var mcpServer in mcpServers.Values) { diff --git a/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Proxies/StreamableHttpHandlerProxy.cs b/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Proxies/StreamableHttpHandlerProxy.cs index 43e05696589..e8ec7098341 100644 --- a/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Proxies/StreamableHttpHandlerProxy.cs +++ b/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Proxies/StreamableHttpHandlerProxy.cs @@ -14,41 +14,41 @@ public StreamableHttpHandlerProxy(McpRequestExecutorProxy mcpRequestExecutor) public async Task HandlePostRequestAsync(HttpContext context) { - var session = await _mcpRequestExecutor.GetOrCreateSessionAsync(context.RequestAborted); - await session.StreamableHttpHandler.HandlePostRequestAsync(context); + var handler = + await _mcpRequestExecutor.GetStreamableHttpHandlerAsync(context.RequestAborted); + await handler.HandlePostRequestAsync(context); } public async Task HandleGetRequestAsync(HttpContext context) { - var session = await _mcpRequestExecutor.GetOrCreateSessionAsync(context.RequestAborted); + var handler = + await _mcpRequestExecutor.GetStreamableHttpHandlerAsync(context.RequestAborted); - if (IsStateless(session)) + if (handler.HttpServerTransportOptions.Stateless) { WriteMethodNotAllowed(context); return; } - await session.StreamableHttpHandler.HandleGetRequestAsync(context); + await handler.HandleGetRequestAsync(context); } public async Task HandleDeleteRequestAsync(HttpContext context) { - var session = await _mcpRequestExecutor.GetOrCreateSessionAsync(context.RequestAborted); + var handler = + await _mcpRequestExecutor.GetStreamableHttpHandlerAsync(context.RequestAborted); - if (IsStateless(session)) + if (handler.HttpServerTransportOptions.Stateless) { WriteMethodNotAllowed(context); return; } - await session.StreamableHttpHandler.HandleDeleteRequestAsync(context); + await handler.HandleDeleteRequestAsync(context); } - private static bool IsStateless(McpExecutorSession session) - => session.StreamableHttpHandler.HttpServerTransportOptions.Stateless; - private static void WriteMethodNotAllowed(HttpContext context) { context.Response.Headers.Allow = "POST"; diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/HttpRequestExecutorProxy.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/HttpRequestExecutorProxy.cs index f33407fea6b..945df75004d 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/HttpRequestExecutorProxy.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/HttpRequestExecutorProxy.cs @@ -1,4 +1,3 @@ -using HotChocolate.Features; using Microsoft.Extensions.DependencyInjection; namespace HotChocolate.AspNetCore; @@ -19,14 +18,13 @@ public async ValueTask GetOrCreateSessionAsync(CancellationToke } var executor = await GetExecutorAsync(cancellationToken); - return executor.Features.GetRequired(); + + return GetSession(executor); } protected override void OnConfigureRequestExecutor(IRequestExecutor newExecutor, IRequestExecutor? oldExecutor) { - var session = new ExecutorSession(newExecutor); - newExecutor.Features.Set(session); - _session = session; + _session = GetSession(newExecutor); } public static HttpRequestExecutorProxy Create(IServiceProvider services, string schemaName) @@ -35,4 +33,7 @@ public static HttpRequestExecutorProxy Create(IServiceProvider services, string var executorEvents = services.GetRequiredService(); return new HttpRequestExecutorProxy(executorProvider, executorEvents, schemaName); } + + private static ExecutorSession GetSession(IRequestExecutor executor) + => executor.Schema.Services.GetService() ?? new ExecutorSession(executor); } diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.cs b/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.cs index 0f4610cfb01..bb970910c26 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.cs @@ -160,6 +160,9 @@ private static IRequestExecutorBuilder AddGraphQLServerCore( .Get(schemaName); return new ExecutionConcurrencyGate(serverOptions.MaxConcurrentExecutions); }); + + s.TryAddSingleton( + static sp => new ExecutorSession(sp.GetRequiredService())); }); builder.Services.TryAddEnumerable( diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/HttpRequestExecutorProxyTests.cs b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/HttpRequestExecutorProxyTests.cs new file mode 100644 index 00000000000..256ac7f973c --- /dev/null +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/HttpRequestExecutorProxyTests.cs @@ -0,0 +1,88 @@ +using HotChocolate.Execution; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.AspNetCore; + +public class HttpRequestExecutorProxyTests +{ + [Fact] + public async Task GetOrCreateSessionAsync_Should_ReturnSameSession_When_ProxiesShareExecutor() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await using var services = CreateServices(); + using var first = HttpRequestExecutorProxy.Create(services, ISchemaDefinition.DefaultName); + using var second = HttpRequestExecutorProxy.Create(services, ISchemaDefinition.DefaultName); + + // act + var firstSession = await first.GetOrCreateSessionAsync(cancellationToken); + var secondSession = await second.GetOrCreateSessionAsync(cancellationToken); + + // assert + Assert.Same(firstSession, secondSession); + } + + [Theory] + [InlineData(2)] + [InlineData(16)] + public async Task GetOrCreateSessionAsync_Should_ReturnOneSession_When_ProxiesStartConcurrently( + int proxyCount) + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + var sessionCountsPerExecutor = new List(); + + // act + for (var i = 0; i < 20; i++) + { + await using var services = CreateServices(); + await services + .GetRequiredService() + .GetExecutorAsync(cancellationToken: cancellationToken); + + var proxies = new HttpRequestExecutorProxy[proxyCount]; + + for (var j = 0; j < proxyCount; j++) + { + proxies[j] = HttpRequestExecutorProxy.Create( + services, + ISchemaDefinition.DefaultName); + } + + var start = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var sessionTasks = proxies + .Select( + async proxy => + { + await start.Task; + return await proxy.GetOrCreateSessionAsync(cancellationToken); + }) + .ToArray(); + start.SetResult(); + var sessions = await Task.WhenAll(sessionTasks); + + sessionCountsPerExecutor.Add(sessions.Distinct().Count()); + + foreach (var proxy in proxies) + { + proxy.Dispose(); + } + } + + // assert + Assert.All(sessionCountsPerExecutor, count => Assert.Equal(1, count)); + } + + private static ServiceProvider CreateServices() + => new ServiceCollection() + .AddGraphQLServer() + .AddQueryType() + .Services + .BuildServiceProvider(); + + public sealed class Query + { + public string Greeting => "hello"; + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.AspNetCore/DependencyInjection/FusionServerServiceCollectionExtensions.cs b/src/HotChocolate/Fusion/src/Fusion.AspNetCore/DependencyInjection/FusionServerServiceCollectionExtensions.cs index 7ea5f14cddf..c6dabb7efd5 100644 --- a/src/HotChocolate/Fusion/src/Fusion.AspNetCore/DependencyInjection/FusionServerServiceCollectionExtensions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.AspNetCore/DependencyInjection/FusionServerServiceCollectionExtensions.cs @@ -93,6 +93,9 @@ private static IFusionGatewayBuilder AddGraphQLGatewayServerCore( .Get(schemaName); return new ExecutionConcurrencyGate(serverOptions.MaxConcurrentExecutions); }); + + sc.TryAddSingleton( + static sp => new ExecutorSession(sp.GetRequiredService())); }); return builder;