Skip to content

Use HTTP2 full duplex for committing session #561

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jan 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions Microsoft.AspNetCore.SystemWebAdapters.slnLaunch
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
]
64 changes: 64 additions & 0 deletions designs/remote-session.md
Original file line number Diff line number Diff line change
@@ -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
```
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// This is an implementation of <see cref="ISessionManager"/> that connects to an upstream session store.
/// <list type="bullet">
/// <item>If the request is readonly, it will close the remote connection and return the session state.</item>
/// <item>If the request is not readonly, it will hold onto the remote connection and initiate a new connection to PUT the results.</item>
/// </list>
/// </summary>
/// <remarks>
/// For the non-readonly mode, it is preferrable to use <see cref="SingleConnectionWriteableRemoteAppSessionStateManager"/> instead
/// which will only use a single connection via HTTP2 streaming.
/// </remarks>
internal sealed class DoubleConnectionRemoteAppSessionManager(
ISessionSerializer serializer,
IOptions<RemoteAppSessionStateClientOptions> options,
IOptions<RemoteAppClientOptions> remoteAppClientOptions,
ILogger<DoubleConnectionRemoteAppSessionManager> logger
) : RemoteAppSessionStateManager(serializer, options, remoteAppClientOptions, logger)
{
public Task<ISessionState> 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<ISessionState> 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();
}
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// This is used to dispatch to either <see cref="DoubleConnectionRemoteAppSessionManager"/> if in read-only mode or <see cref="SingleConnectionWriteableRemoteAppSessionStateManager"/> if not.
/// </summary>
internal sealed partial class RemoteAppSessionDispatcher : ISessionManager
{
private readonly IOptions<RemoteAppSessionStateClientOptions> _options;
private readonly ISessionManager _singleConnection;
private readonly ISessionManager _doubleConnection;
private readonly ILogger _logger;

/// <summary>
/// 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.
/// </summary>
public static ISessionManager Create(
IOptions<RemoteAppSessionStateClientOptions> options,
ISessionManager singleConnection,
ISessionManager doubleConnection,
ILogger logger
)
{
return new RemoteAppSessionDispatcher(options, singleConnection, doubleConnection, logger);
}

public RemoteAppSessionDispatcher(
IOptions<RemoteAppSessionStateClientOptions> options,
SingleConnectionWriteableRemoteAppSessionStateManager singleConnection,
DoubleConnectionRemoteAppSessionManager doubleConnection,
ILogger<RemoteAppSessionDispatcher> logger
)
: this(options, (ISessionManager)singleConnection, doubleConnection, logger)
{
}

private RemoteAppSessionDispatcher(
IOptions<RemoteAppSessionStateClientOptions> options,
ISessionManager singleConnection,
ISessionManager doubleConnection,
ILogger logger)
{
_options = options;
_singleConnection = singleConnection;
_doubleConnection = doubleConnection;
_logger = logger;
}

public async Task<ISessionState> 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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ public PathString SessionEndpointPath
set => Path = new(value);
}

/// <summary>
/// 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 <c>false</c>.
/// </summary>
public bool UseSingleConnection { get; set; } = true;

internal RelativePathString Path { get; private set; } = new(SessionConstants.SessionEndpointPath);

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<RemoteAppSessionStateClientOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(builder);

builder.Services.AddTransient<ISessionManager, RemoteAppSessionStateManager>();
builder.Services.AddTransient<DoubleConnectionRemoteAppSessionManager>();
builder.Services.AddTransient<SingleConnectionWriteableRemoteAppSessionStateManager>();
builder.Services.AddTransient<ISessionManager, RemoteAppSessionDispatcher>();

builder.Services.AddOptions<RemoteAppSessionStateClientOptions>()
.Configure(configure ?? (_ => { }))
.PostConfigure<IOptions<RemoteAppClientOptions>, ILogger<RemoteAppClientOptions>>((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;
Expand Down
Loading