diff --git a/Microsoft.AspNetCore.SystemWebAdapters.slnLaunch b/Microsoft.AspNetCore.SystemWebAdapters.slnLaunch index 0970ee7e9..4f78236c1 100644 --- a/Microsoft.AspNetCore.SystemWebAdapters.slnLaunch +++ b/Microsoft.AspNetCore.SystemWebAdapters.slnLaunch @@ -100,5 +100,18 @@ "Action": "Start" } ] + }, + { + "Name": "Sample: Remote Session", + "Projects": [ + { + "Path": "samples\\RemoteSession\\RemoteSessionCore\\RemoteSessionCore.csproj", + "Action": "Start" + }, + { + "Path": "samples\\RemoteSession\\RemoteSessionFramework\\RemoteSessionFramework.csproj", + "Action": "Start" + } + ] } ] \ No newline at end of file diff --git a/designs/remote-session.md b/designs/remote-session.md new file mode 100644 index 000000000..2ec63dbb1 --- /dev/null +++ b/designs/remote-session.md @@ -0,0 +1,64 @@ +# Remote session Protocol + +## Readonly + +Readonly session will retrieve the session state from the framework app without any sort of locking. This consists of a single `GET` request that will return a session state and can be closed immediately. + +```mermaid + sequenceDiagram + participant core as ASP.NET Core + participant framework as ASP.NET + participant session as Session Store + core ->> +framework: GET /session + framework ->> session: Request session + session -->> framework: Session + framework -->> -core: Session + core ->> core: Run request +``` + +## Writeable (single connection with HTTP2 and SSL) + +Writeable session state protocol consists of a `POST` request that requires streaming over HTTP2 full-duplex. + +```mermaid + sequenceDiagram + participant core as ASP.NET Core + participant framework as ASP.NET + participant session as Session Store + core ->> +framework: POST /session + framework ->> session: Request session + session -->> framework: Session + framework -->> core: Session + core ->> core: Run request + core ->> framework: Updated session state + framework ->> session: Persist + framework -->> -core: Persist result (JSON) +``` + +## Writeable (two connections when HTTP2 or SSL are unavailable) + +Writeable session state protocol starts with the the same as the readonly, but differs in the following: + +- Requires an additional `PUT` request to update the state +- The initial `GET` request must be kept open until the session is done; if closed, the session will not be able to be updated +- A lock store (implemented internally as `ILockedSessionCache`) is used to track active open requests + +```mermaid + sequenceDiagram + participant core as ASP.NET Core + participant framework as ASP.NET + participant store as Lock Store + participant session as Session Store + core ->> +framework: GET /session + framework ->> session: Request session + session -->> framework: Session + framework -->> +store: Register active request + framework -->> core: Session + core ->> core: Run request + core ->> +framework: PUT /session + framework ->> store: Finalize session + store ->> session: Persist + store ->> -framework: Notify complete + framework ->> -core: Persist complete + framework -->> -core: Session complete +``` diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/DoubleConnectionRemoteAppSessionManager.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/DoubleConnectionRemoteAppSessionManager.cs new file mode 100644 index 000000000..5fd895751 --- /dev/null +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/DoubleConnectionRemoteAppSessionManager.cs @@ -0,0 +1,129 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.RemoteSession; + +/// +/// This is an implementation of that connects to an upstream session store. +/// +/// If the request is readonly, it will close the remote connection and return the session state. +/// If the request is not readonly, it will hold onto the remote connection and initiate a new connection to PUT the results. +/// +/// +/// +/// For the non-readonly mode, it is preferrable to use instead +/// which will only use a single connection via HTTP2 streaming. +/// +internal sealed class DoubleConnectionRemoteAppSessionManager( + ISessionSerializer serializer, + IOptions options, + IOptions remoteAppClientOptions, + ILogger logger + ) : RemoteAppSessionStateManager(serializer, options, remoteAppClientOptions, logger) +{ + public Task GetReadOnlySessionStateAsync(HttpContextCore context) => CreateAsync(context, isReadOnly: true); + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", Justification = "They are either passed into another object or are manually disposed")] + protected override async Task GetSessionDataAsync(string? sessionId, bool readOnly, HttpContextCore callingContext, CancellationToken token) + { + // The request message is manually disposed at a later time + var request = new HttpRequestMessage(HttpMethod.Get, Options.Path.Relative); + + AddSessionCookieToHeader(request, sessionId); + AddReadOnlyHeader(request, readOnly); + + var response = await BackchannelClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token); + + LogRetrieveResponse(response.StatusCode); + + response.EnsureSuccessStatusCode(); + + var remoteSessionState = await Serializer.DeserializeAsync(await response.Content.ReadAsStreamAsync(token), token); + + if (remoteSessionState is null) + { + throw new InvalidOperationException("Could not retrieve remote session state"); + } + + // Propagate headers back to the caller since a new session ID may have been set + // by the remote app if there was no session active previously or if the previous + // session expired. + PropagateHeaders(response, callingContext, HeaderNames.SetCookie); + + if (remoteSessionState.IsReadOnly) + { + request.Dispose(); + response.Dispose(); + return remoteSessionState; + } + + return new RemoteSessionState(remoteSessionState, request, response, this); + } + + private sealed class SerializedSessionHttpContent : HttpContent + { + private readonly ISessionSerializer _serializer; + private readonly ISessionState _state; + + public SerializedSessionHttpContent(ISessionSerializer serializer, ISessionState state) + { + _serializer = serializer; + _state = state; + } + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) + => SerializeToStreamAsync(stream, context, default); + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken) + => _serializer.SerializeAsync(_state, stream, cancellationToken); + + protected override bool TryComputeLength(out long length) + { + length = 0; + return false; + } + } + + private sealed class RemoteSessionState(ISessionState other, HttpRequestMessage request, HttpResponseMessage response, DoubleConnectionRemoteAppSessionManager manager) : DelegatingSessionState + { + protected override ISessionState State => other; + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + if (disposing) + { + request.Dispose(); + response.Dispose(); + } + } + + public override async Task CommitAsync(CancellationToken token) + { + using var request = new HttpRequestMessage(HttpMethod.Put, manager.Options.Path.Relative) + { + Content = new SerializedSessionHttpContent(manager.Serializer, State) + }; + + manager.AddSessionCookieToHeader(request, State.SessionID); + + using var response = await manager.BackchannelClient.SendAsync(request, token); + + manager.LogCommitResponse(response.StatusCode); + + response.EnsureSuccessStatusCode(); + } + } +} diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/RemoteAppSessionDispatcher.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/RemoteAppSessionDispatcher.cs new file mode 100644 index 000000000..448a81a8b --- /dev/null +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/RemoteAppSessionDispatcher.cs @@ -0,0 +1,111 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.RemoteSession; + +/// +/// This is used to dispatch to either if in read-only mode or if not. +/// +internal sealed partial class RemoteAppSessionDispatcher : ISessionManager +{ + private readonly IOptions _options; + private readonly ISessionManager _singleConnection; + private readonly ISessionManager _doubleConnection; + private readonly ILogger _logger; + + /// + /// This method is used to test the behavior of this class since it can take any ISessionManage instances. Once we drop support for .NET 6, we should be able to use keyed services + /// in the DI container to achieve the same effect with just a constructor. + /// + public static ISessionManager Create( + IOptions options, + ISessionManager singleConnection, + ISessionManager doubleConnection, + ILogger logger + ) + { + return new RemoteAppSessionDispatcher(options, singleConnection, doubleConnection, logger); + } + + public RemoteAppSessionDispatcher( + IOptions options, + SingleConnectionWriteableRemoteAppSessionStateManager singleConnection, + DoubleConnectionRemoteAppSessionManager doubleConnection, + ILogger logger + ) + : this(options, (ISessionManager)singleConnection, doubleConnection, logger) + { + } + + private RemoteAppSessionDispatcher( + IOptions options, + ISessionManager singleConnection, + ISessionManager doubleConnection, + ILogger logger) + { + _options = options; + _singleConnection = singleConnection; + _doubleConnection = doubleConnection; + _logger = logger; + } + + public async Task CreateAsync(HttpContextCore context, SessionAttribute metadata) + { + if (metadata.IsReadOnly) + { + // In readonly mode it's a simple GET request + return await _doubleConnection.CreateAsync(context, metadata); + } + + if (_options.Value.UseSingleConnection) + { + try + { + return await _singleConnection.CreateAsync(context, metadata); + } + + // We can attempt to discover if the server supports the single connection. If it doesn't, + // future attempts will fallback to the double until the option value is reset. + catch (HttpRequestException ex) when (ServerDoesNotSupportSingleConnection(ex)) + { + LogServerDoesNotSupportSingleConnection(ex); + _options.Value.UseSingleConnection = false; + } + catch (Exception ex) + { + LogServerFailedSingelConnection(ex); + throw; + } + } + + return await _doubleConnection.CreateAsync(context, metadata); + } + + private static bool ServerDoesNotSupportSingleConnection(HttpRequestException ex) + { +#if NET8_0_OR_GREATER + // This is thrown when HTTP2 cannot be initiated + if (ex.HttpRequestError == HttpRequestError.HttpProtocolError) + { + return true; + } +#endif + + // This is thrown if the server does not know about the POST verb + return ex.StatusCode == HttpStatusCode.MethodNotAllowed; + } + + [LoggerMessage(0, LogLevel.Warning, "The server does not support the single connection mode for remote session. Falling back to double connection mode. This must be manually reset to try again.")] + private partial void LogServerDoesNotSupportSingleConnection(HttpRequestException ex); + + [LoggerMessage(1, LogLevel.Error, "Failed to connect to server with an unknown reason")] + private partial void LogServerFailedSingelConnection(Exception ex); +} diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/RemoteAppSessionStateClientOptions.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/RemoteAppSessionStateClientOptions.cs index 34ed3a503..b4135cc79 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/RemoteAppSessionStateClientOptions.cs +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/RemoteAppSessionStateClientOptions.cs @@ -15,6 +15,11 @@ public PathString SessionEndpointPath set => Path = new(value); } + /// + /// Gets or sets whether a single connection should be used. If the framework deployment is the source of truth that doesn't support the single connection mode (such as it can't support HTTP2), set this to false. + /// + public bool UseSingleConnection { get; set; } = true; + internal RelativePathString Path { get; private set; } = new(SessionConstants.SessionEndpointPath); /// diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/RemoteAppSessionStateExtensions.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/RemoteAppSessionStateExtensions.cs index 6aad452f8..aec690c1f 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/RemoteAppSessionStateExtensions.cs +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/RemoteAppSessionStateExtensions.cs @@ -4,19 +4,35 @@ using System; using Microsoft.AspNetCore.SystemWebAdapters; using Microsoft.AspNetCore.SystemWebAdapters.SessionState.RemoteSession; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Microsoft.Extensions.DependencyInjection; -public static class RemoteAppSessionStateExtensions +public static partial class RemoteAppSessionStateExtensions { + [LoggerMessage(0, LogLevel.Warning, "The remote app session client is configured to use a single connection, but the remote app URL is not HTTPS. Disabling single connection mode.")] + private static partial void LogSingleConnectionDisabled(ILogger logger); + public static ISystemWebAdapterRemoteClientAppBuilder AddSessionClient(this ISystemWebAdapterRemoteClientAppBuilder builder, Action? configure = null) { ArgumentNullException.ThrowIfNull(builder); - builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddOptions() .Configure(configure ?? (_ => { })) + .PostConfigure, ILogger>((options, remote, logger) => + { + // The single connection remote app session client requires https to work so if that's not the case, we'll disable it + if (options.UseSingleConnection && !string.Equals(remote.Value.RemoteAppUrl.Scheme, "https", StringComparison.OrdinalIgnoreCase)) + { + LogSingleConnectionDisabled(logger); + options.UseSingleConnection = false; + } + }) .ValidateDataAnnotations(); return builder; diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/RemoteAppSessionStateManager.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/RemoteAppSessionStateManager.cs index ea95f19ba..a5fe6f8d3 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/RemoteAppSessionStateManager.cs +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/RemoteAppSessionStateManager.cs @@ -2,10 +2,13 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Buffers; using System.IO; using System.Linq; using System.Net; using System.Net.Http; +using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; @@ -16,48 +19,54 @@ namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.RemoteSession; -internal partial class RemoteAppSessionStateManager : ISessionManager +internal abstract partial class RemoteAppSessionStateManager : ISessionManager { - private readonly ISessionSerializer _serializer; - private readonly ILogger _logger; - private readonly RemoteAppSessionStateClientOptions _options; - private readonly HttpClient _backchannelClient; + private readonly ILogger _logger; - public RemoteAppSessionStateManager( + protected RemoteAppSessionStateManager( ISessionSerializer serializer, IOptions options, IOptions remoteAppClientOptions, - ILogger logger) + ILogger logger) { - _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); - _backchannelClient = remoteAppClientOptions?.Value.BackchannelClient ?? throw new ArgumentNullException(nameof(remoteAppClientOptions)); + Options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + BackchannelClient = remoteAppClientOptions?.Value.BackchannelClient ?? throw new ArgumentNullException(nameof(remoteAppClientOptions)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _serializer = serializer ?? throw new ArgumentNullException(nameof(serializer)); + Serializer = serializer ?? throw new ArgumentNullException(nameof(serializer)); } + protected ISessionSerializer Serializer { get; } + + protected RemoteAppSessionStateClientOptions Options { get; } + + protected HttpClient BackchannelClient { get; } + [LoggerMessage(EventId = 0, Level = LogLevel.Debug, Message = "Loaded {Count} items from remote session state for session {SessionId}")] - private partial void LogSessionLoad(int count, string sessionId); + protected partial void LogSessionLoad(int count, string sessionId); [LoggerMessage(EventId = 1, Level = LogLevel.Error, Message = "Unable to load remote session state for session {SessionId}")] - private partial void LogRemoteSessionException(Exception exc, string? sessionId); + protected partial void LogRemoteSessionException(Exception exc, string? sessionId); [LoggerMessage(EventId = 2, Level = LogLevel.Trace, Message = "Received {StatusCode} response getting remote session state")] - private partial void LogRetrieveResponse(HttpStatusCode statusCode); + protected partial void LogRetrieveResponse(HttpStatusCode statusCode); [LoggerMessage(EventId = 3, Level = LogLevel.Trace, Message = "Received {StatusCode} response committing remote session state")] - private partial void LogCommitResponse(HttpStatusCode statusCode); + protected partial void LogCommitResponse(HttpStatusCode statusCode); - public async Task CreateAsync(HttpContextCore context, SessionAttribute metadata) + public Task CreateAsync(HttpContextCore context, SessionAttribute metadata) + => CreateAsync(context, metadata.IsReadOnly); + + protected async Task CreateAsync(HttpContextCore context, bool isReadOnly) { // If an existing remote session ID is present in the request, use its session ID. // Otherwise, leave session ID null for now; it will be provided by the remote service // when session data is loaded. - var sessionId = context.Request.Cookies[_options.CookieName]; + var sessionId = context.Request.Cookies[Options.CookieName]; try { // Get or create session data - var response = await GetSessionDataAsync(sessionId, metadata.IsReadOnly, context, context.RequestAborted); + var response = await GetSessionDataAsync(sessionId, isReadOnly, context, context.RequestAborted); LogSessionLoad(response.Count, response.SessionID); @@ -70,64 +79,9 @@ public async Task CreateAsync(HttpContextCore context, SessionAtt } } - private async Task GetSessionDataAsync(string? sessionId, bool readOnly, HttpContextCore callingContext, CancellationToken token) - { - // The request message is manually disposed at a later time -#pragma warning disable CA2000 // Dispose objects before losing scope - var req = new HttpRequestMessage(HttpMethod.Get, _options.Path.Relative); -#pragma warning restore CA2000 // Dispose objects before losing scope - - AddSessionCookieToHeader(req, sessionId); - AddReadOnlyHeader(req, readOnly); - - var response = await _backchannelClient.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, token); - - LogRetrieveResponse(response.StatusCode); - - response.EnsureSuccessStatusCode(); - - var remoteSessionState = await _serializer.DeserializeAsync(await response.Content.ReadAsStreamAsync(token), token); + protected abstract Task GetSessionDataAsync(string? sessionId, bool readOnly, HttpContextCore callingContext, CancellationToken token); - if (remoteSessionState is null) - { - throw new InvalidOperationException("Could not retrieve remote session state"); - } - - // Propagate headers back to the caller since a new session ID may have been set - // by the remote app if there was no session active previously or if the previous - // session expired. - PropagateHeaders(response, callingContext, HeaderNames.SetCookie); - - if (remoteSessionState.IsReadOnly) - { - response.Dispose(); - return remoteSessionState; - } - - return new RemoteSessionState(remoteSessionState, response, SetOrReleaseSessionData); - } - - /// - /// Commits changes to the server. Passing null will release the session lock but not update session data. - /// - private async Task SetOrReleaseSessionData(ISessionState? state, CancellationToken cancellationToken) - { - using var req = new HttpRequestMessage(HttpMethod.Put, _options.Path.Relative); - - if (state is not null) - { - AddSessionCookieToHeader(req, state.SessionID); - req.Content = new SerializedSessionHttpContent(_serializer, state); - } - - using var response = await _backchannelClient.SendAsync(req, cancellationToken); - - LogCommitResponse(response.StatusCode); - - response.EnsureSuccessStatusCode(); - } - - private static void PropagateHeaders(HttpResponseMessage responseMessage, HttpContextCore context, string cookieName) + protected static void PropagateHeaders(HttpResponseMessage responseMessage, HttpContextCore context, string cookieName) { if (context?.Response is not null && responseMessage.Headers.TryGetValues(cookieName, out var cookieValues)) { @@ -135,38 +89,14 @@ private static void PropagateHeaders(HttpResponseMessage responseMessage, HttpCo } } - private void AddSessionCookieToHeader(HttpRequestMessage req, string? sessionId) + protected void AddSessionCookieToHeader(HttpRequestMessage req, string? sessionId) { if (!string.IsNullOrEmpty(sessionId)) { - req.Headers.Add(HeaderNames.Cookie, $"{_options.CookieName}={sessionId}"); + req.Headers.Add(HeaderNames.Cookie, $"{Options.CookieName}={sessionId}"); } } - private static void AddReadOnlyHeader(HttpRequestMessage req, bool readOnly) + protected static void AddReadOnlyHeader(HttpRequestMessage req, bool readOnly) => req.Headers.Add(SessionConstants.ReadOnlyHeaderName, readOnly.ToString()); - - private class SerializedSessionHttpContent : HttpContent - { - private readonly ISessionSerializer _serializer; - private readonly ISessionState _state; - - public SerializedSessionHttpContent(ISessionSerializer serializer, ISessionState state) - { - _serializer = serializer; - _state = state; - } - - protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) - => SerializeToStreamAsync(stream, context, default); - - protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken) - => _serializer.SerializeAsync(_state, stream, cancellationToken); - - protected override bool TryComputeLength(out long length) - { - length = 0; - return false; - } - } } diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/RemoteSessionState.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/RemoteSessionState.cs deleted file mode 100644 index c266c0e92..000000000 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/RemoteSessionState.cs +++ /dev/null @@ -1,45 +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 System; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.RemoteSession; - -internal class RemoteSessionState : DelegatingSessionState -{ - private HttpResponseMessage? _response; - private Func? _onCommit; - - public RemoteSessionState(ISessionState other, HttpResponseMessage response, Func onCommit) - { - State = other; - _response = response; - _onCommit = onCommit; - } - - protected override ISessionState State { get; } - - protected override void Dispose(bool disposing) - { - base.Dispose(disposing); - - if (disposing && _response is not null) - { - _response.Dispose(); - _response = null; - } - } - - public override async Task CommitAsync(CancellationToken token) - { - if (_onCommit is { } onCommit) - { - _onCommit = null; - await onCommit(State, token); - } - } -} - diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/SingleConnectionWriteableRemoteAppSessionStateManager.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/SingleConnectionWriteableRemoteAppSessionStateManager.cs new file mode 100644 index 000000000..58538cc2f --- /dev/null +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/SingleConnectionWriteableRemoteAppSessionStateManager.cs @@ -0,0 +1,150 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.RemoteSession; + +/// +/// An implementation of that uses HTTP2 streaming to POST to session endpoint with a single connection. +/// +/// +/// This only supports non-readonly mode. For readonly mode, should be used. An additional implementation +/// of is available that handles the dispatching to the correct implementation. See for that. +/// +internal sealed partial class SingleConnectionWriteableRemoteAppSessionStateManager( + ISessionSerializer serializer, + IOptions options, + IOptions remoteAppClientOptions, + ILogger logger + ) : RemoteAppSessionStateManager(serializer, options, remoteAppClientOptions, logger) +{ + [System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", Justification = "Dispose is handled in the returned ISessionState")] + protected override async Task GetSessionDataAsync(string? sessionId, bool readOnly, HttpContextCore callingContext, CancellationToken token) + { + if (readOnly) + { + throw new InvalidOperationException("This manager is only intended for writeable session"); + } + + var content = new CommittingSessionHttpContent(Serializer); + + // The request message is manually disposed at a later time + var request = new HttpRequestMessage(HttpMethod.Post, Options.Path.Relative) + { + Content = content, + Version = new Version(2, 0), + VersionPolicy = HttpVersionPolicy.RequestVersionOrHigher, + }; + + AddSessionCookieToHeader(request, sessionId); + AddReadOnlyHeader(request, readOnly); + + var response = await BackchannelClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token); + + LogRetrieveResponse(response.StatusCode); + + response.EnsureSuccessStatusCode(); + + var responseStream = await response.Content.ReadAsStreamAsync(token); + var remoteSessionState = await Serializer.DeserializeAsync(responseStream, token); + + if (remoteSessionState is null) + { + if (responseStream is { }) + { + await responseStream.DisposeAsync(); + } + + response.Dispose(); + request.Dispose(); + content.Dispose(); + + throw new InvalidOperationException("Could not retrieve remote session state"); + } + + // Propagate headers back to the caller since a new session ID may have been set + // by the remote app if there was no session active previously or if the previous + // session expired. + PropagateHeaders(response, callingContext, HeaderNames.SetCookie); + + return new RemoteSessionState(remoteSessionState, request, response, content, responseStream); + } + + [JsonSerializable(typeof(SessionPostResult))] + private sealed partial class SessionPostResultContext : JsonSerializerContext + { + } + + private sealed class CommittingSessionHttpContent : HttpContent + { + private readonly TaskCompletionSource _state; + + public CommittingSessionHttpContent(ISessionSerializer serializer) + { + Serializer = serializer; + _state = new(); + } + + public ISessionSerializer Serializer { get; } + + public void Commit(ISessionState state) => _state.SetResult(state); + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) + => SerializeToStreamAsync(stream, context, default); + + protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken) + { + await stream.FlushAsync(cancellationToken); + var state = await _state.Task; + await Serializer.SerializeAsync(state, stream, cancellationToken); + } + + protected override bool TryComputeLength(out long length) + { + length = 0; + return false; + } + } + + private sealed class RemoteSessionState(ISessionState other, HttpRequestMessage request, HttpResponseMessage response, CommittingSessionHttpContent content, Stream stream) : DelegatingSessionState + { + protected override ISessionState State => other; + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + if (disposing) + { + stream.Dispose(); + response.Dispose(); + content.Dispose(); + request.Dispose(); + } + } + + public override async Task CommitAsync(CancellationToken token) + { + content.Commit(State); + + var result = await JsonSerializer.DeserializeAsync(stream, SessionPostResultContext.Default.SessionPostResult, token); + + if (result is not { Success: true }) + { + throw new InvalidOperationException("Failed to commit session state"); + } + } + } +} diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/IRequireBufferlessStream.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/IRequireBufferlessStream.cs new file mode 100644 index 000000000..34dd79b3a --- /dev/null +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/IRequireBufferlessStream.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Web; + +namespace Microsoft.AspNetCore.SystemWebAdapters; + +/// +/// Used to mark instances that require bufferless stream for input in order +/// to perform streaming operations. +/// +internal interface IRequireBufferlessStream +{ +} diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/RemoteModule.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/RemoteModule.cs index 518e28177..64b58569f 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/RemoteModule.cs +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/RemoteModule.cs @@ -12,6 +12,7 @@ internal abstract class RemoteModule : IHttpModule private Func? _get; private Func? _put; + private Func? _post; protected RemoteModule(IOptions options) { @@ -26,6 +27,9 @@ protected void MapGet(Func handler) protected void MapPut(Func handler) => _put = handler; + protected void MapPost(Func handler) + => _post = handler; + protected bool HasValidApiKey(HttpContextBase context) { var apiKey = context.Request.Headers.Get(_options.Value.ApiKeyHeader); @@ -70,6 +74,20 @@ void IHttpModule.Init(HttpApplication context) { context.ApplicationInstance.CompleteRequest(); } + + if (context.Handler is IRequireBufferlessStream) + { + if (context.Request.ReadEntityBodyMode is ReadEntityBodyMode.None or ReadEntityBodyMode.Bufferless) + { + context.Request.GetBufferlessInputStream(); + } + else + { + context.Response.StatusCode = 400; + context.Response.Write("Bufferless stream is required."); + context.ApplicationInstance.CompleteRequest(); + } + } }; } @@ -86,6 +104,10 @@ public void HandleRequest(HttpContextBase context) { context.Handler = put(context); } + else if (string.Equals("POST", context.Request.HttpMethod, StringComparison.OrdinalIgnoreCase) && _post is { } post) + { + context.Handler = post(context); + } else { context.Response.StatusCode = 405; diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/ReadWriteSessionHandler.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/ReadWriteSessionHandler.cs new file mode 100644 index 000000000..2547ed886 --- /dev/null +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/ReadWriteSessionHandler.cs @@ -0,0 +1,89 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Web; +using System.Web.SessionState; +using Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; + +namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.RemoteSession; + +internal sealed partial class ReadWriteSessionHandler : HttpTaskAsyncHandler, IRequiresSessionState, IRequireBufferlessStream +{ + private readonly ISessionSerializer _serializer; + + public ReadWriteSessionHandler(ISessionSerializer serializer) + { + _serializer = serializer; + } + + public override async Task ProcessRequestAsync(HttpContext context) + { + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(context.Session.Timeout)); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, context.Response.ClientDisconnectedToken); + + var contextWrapper = new HttpContextWrapper(context); + + await SendSessionAsync(contextWrapper, cts.Token).ConfigureAwait(false); + + if (await RetrieveUpdatedSessionAsync(contextWrapper, cts.Token)) + { + await SendSessionWriteResultAsync(contextWrapper.Response, Results.Succeeded, cts.Token); + } + else + { + await SendSessionWriteResultAsync(contextWrapper.Response, Results.NoSessionData, cts.Token); + } + + context.ApplicationInstance.CompleteRequest(); + } + + private async Task SendSessionAsync(HttpContextBase context, CancellationToken token) + { + // Send the initial snapshot of session data + context.Response.ContentType = "text/event-stream"; + context.Response.StatusCode = 200; + + using var wrapper = new HttpSessionStateBaseWrapper(context.Session); + + await _serializer.SerializeAsync(wrapper, context.Response.OutputStream, token); + + // Ensure to call HttpResponse.FlushAsync to flush the request itself, and not context.Response.OutputStream.FlushAsync() + await context.Response.FlushAsync(); + } + + private async Task RetrieveUpdatedSessionAsync(HttpContextBase context, CancellationToken token) + { + // This will wait for data to be pushed for the session info to be committed + using var stream = context.Request.GetBufferlessInputStream(); + + using var deserialized = await _serializer.DeserializeAsync(stream, token); + + if (deserialized is { }) + { + deserialized.CopyTo(context.Session); + return true; + } + else + { + return false; + } + } + + private static Task SendSessionWriteResultAsync(HttpResponseBase response, SessionPostResult result, CancellationToken token) + => JsonSerializer.SerializeAsync(response.OutputStream, result, SessionPostResultContext.Default.SessionPostResult, token); + + [JsonSerializable(typeof(SessionPostResult))] + [JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault)] + private partial class SessionPostResultContext : JsonSerializerContext + { + } + + private static class Results + { + public static SessionPostResult Succeeded { get; } = new() { Success = true }; + + public static SessionPostResult NoSessionData { get; } = new() { Success = false, Message = "No session data was supplied for commit" }; + } +} diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/RemoteSessionModule.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/RemoteSessionModule.cs index 479562ac9..aae8ccefe 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/RemoteSessionModule.cs +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/RemoteSessionModule.cs @@ -23,10 +23,12 @@ public RemoteSessionModule(IOptions sessionO var readonlyHandler = new ReadOnlySessionHandler(serializer); var writeableHandler = new GetWriteableSessionHandler(serializer, cache); + var persistHandler = new ReadWriteSessionHandler(serializer); var saveHandler = new StoreSessionStateHandler(cache, options.CookieName); MapGet(context => GetIsReadonly(context.Request) ? readonlyHandler : writeableHandler); MapPut(context => saveHandler); + MapPost(_ => persistHandler); static bool GetIsReadonly(HttpRequestBase request) => bool.TryParse(request.Headers.Get(SessionConstants.ReadOnlyHeaderName), out var result) && result; diff --git a/src/Services/SessionState/SessionPostResult.cs b/src/Services/SessionState/SessionPostResult.cs new file mode 100644 index 000000000..ba15ece25 --- /dev/null +++ b/src/Services/SessionState/SessionPostResult.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Serialization; + +namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.RemoteSession; + +internal class SessionPostResult +{ + [JsonPropertyName("s")] + public bool Success { get; set; } + + [JsonPropertyName("m")] + public string? Message { get; set; } +} diff --git a/test/Microsoft.AspNetCore.SystemWebAdapters.CoreServices.Tests/SessionState/RemoteAppSessionDispatcherTests.cs b/test/Microsoft.AspNetCore.SystemWebAdapters.CoreServices.Tests/SessionState/RemoteAppSessionDispatcherTests.cs new file mode 100644 index 000000000..c635e55dd --- /dev/null +++ b/test/Microsoft.AspNetCore.SystemWebAdapters.CoreServices.Tests/SessionState/RemoteAppSessionDispatcherTests.cs @@ -0,0 +1,100 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using System.Web.SessionState; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.SystemWebAdapters.SessionState.RemoteSession; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.RemoteSession.Tests; + +public class RemoteAppSessionDispatcherTests +{ + [Theory] + [InlineData(true, SessionStateBehavior.ReadOnly, false)] + [InlineData(true, SessionStateBehavior.Required, true)] + [InlineData(false, SessionStateBehavior.ReadOnly, false)] + [InlineData(false, SessionStateBehavior.Required, false)] + public async Task DispatcherTest(bool useSingleConnection, SessionStateBehavior behavior, bool isSingleExpected) + { + // Arrange + var options = new RemoteAppSessionStateClientOptions() + { + UseSingleConnection = useSingleConnection, + }; + + var context = new DefaultHttpContext(); + + var single = new Mock(); + var singleState = new Mock(); + single.Setup(s => s.CreateAsync(It.IsAny(), It.IsAny())).ReturnsAsync(singleState.Object); + + var @double = new Mock(); + var doubleState = new Mock(); + @double.Setup(s => s.CreateAsync(It.IsAny(), It.IsAny())).ReturnsAsync(doubleState.Object); + + var optionsProvider = new Mock>(); + optionsProvider.Setup(o => o.Value).Returns(options); + + var s = RemoteAppSessionDispatcher.Create(optionsProvider.Object, single.Object, @double.Object, new Mock().Object); + var expected = isSingleExpected ? singleState.Object : doubleState.Object; + + // Act + using var state = await s.CreateAsync(context, new SessionAttribute { SessionBehavior = behavior }); + + // Assert + + Assert.Same(expected, state); + } + + [Fact] + public Task HandleServerDoesNotSupportSingleConnection() + => RunError(() => throw new HttpRequestException(null, null, HttpStatusCode.MethodNotAllowed)); + +#if NET8_0_OR_GREATER + [Fact] + public Task HandleHttpProtocolError() + => RunError(() => throw new HttpRequestException(HttpRequestError.HttpProtocolError)); +#endif + + private static async Task RunError(Action action) + { + // Arrange + var options = new RemoteAppSessionStateClientOptions() + { + UseSingleConnection = true, + }; + + var context = new DefaultHttpContext(); + + var single = new Mock(); + var singleState = new Mock(); + single.Setup(s => s.CreateAsync(It.IsAny(), It.IsAny())).Callback(action); + + var @double = new Mock(); + var doubleState = new Mock(); + @double.Setup(s => s.CreateAsync(It.IsAny(), It.IsAny())).ReturnsAsync(doubleState.Object); + + var optionsProvider = new Mock>(); + optionsProvider.Setup(o => o.Value).Returns(options); + + var s = RemoteAppSessionDispatcher.Create(optionsProvider.Object, single.Object, @double.Object, new Mock().Object); + + // Act + using var state = await s.CreateAsync(context, new SessionAttribute { SessionBehavior = SessionStateBehavior.Required }); + + // Assert + Assert.Same(doubleState.Object, state); + } +} + diff --git a/test/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices.Tests/RemoteSession/RemoteSessionModuleTests.cs b/test/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices.Tests/RemoteSession/RemoteSessionModuleTests.cs index 2a681985d..e020e5463 100644 --- a/test/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices.Tests/RemoteSession/RemoteSessionModuleTests.cs +++ b/test/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices.Tests/RemoteSession/RemoteSessionModuleTests.cs @@ -35,7 +35,9 @@ public RemoteSessionModuleTests() [InlineData("PUT", null, 0, ApiKey1, ApiKey1, typeof(StoreSessionStateHandler))] [InlineData("PUT", "true", 0, ApiKey1, ApiKey1, typeof(StoreSessionStateHandler))] [InlineData("PUT", "false", 0, ApiKey1, ApiKey1, typeof(StoreSessionStateHandler))] - [InlineData("POST", null, 405, ApiKey1, ApiKey1, null)] + [InlineData("POST", null, 0, ApiKey1, ApiKey1, typeof(ReadWriteSessionHandler))] + [InlineData("Post", "true", 0, ApiKey1, ApiKey1, typeof(ReadWriteSessionHandler))] + [InlineData("Post", "false", 0, ApiKey1, ApiKey1, typeof(ReadWriteSessionHandler))] [Theory] public void VerifyCorrectHandler(string method, string? readOnlyHeaderValue, int statusCode, string expectedApiKey, string apiKey, Type? handlerType) {