Skip to content

Commit 2a91466

Browse files
authored
Use HTTP2 full duplex for committing session (#561)
The remote session sharing current creates two connections when in non-readonly mode: - One request to GET the session data - One request to PUT the session data This PR adds a POST verb to the endpoint that expects full-duplex streaming to be available and allows for receiving the session state first and then sending the result back once committed. Since this requires HTTP2 and SSL, there is a flag to go back to the original implementation as well. For readonly mode, the original client implementation is used as it calls the GET endpoint and doesn't need to send any results back.
1 parent 73b94a1 commit 2a91466

File tree

16 files changed

+766
-149
lines changed

16 files changed

+766
-149
lines changed

Microsoft.AspNetCore.SystemWebAdapters.slnLaunch

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,5 +100,18 @@
100100
"Action": "Start"
101101
}
102102
]
103+
},
104+
{
105+
"Name": "Sample: Remote Session",
106+
"Projects": [
107+
{
108+
"Path": "samples\\RemoteSession\\RemoteSessionCore\\RemoteSessionCore.csproj",
109+
"Action": "Start"
110+
},
111+
{
112+
"Path": "samples\\RemoteSession\\RemoteSessionFramework\\RemoteSessionFramework.csproj",
113+
"Action": "Start"
114+
}
115+
]
103116
}
104117
]

designs/remote-session.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Remote session Protocol
2+
3+
## Readonly
4+
5+
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.
6+
7+
```mermaid
8+
sequenceDiagram
9+
participant core as ASP.NET Core
10+
participant framework as ASP.NET
11+
participant session as Session Store
12+
core ->> +framework: GET /session
13+
framework ->> session: Request session
14+
session -->> framework: Session
15+
framework -->> -core: Session
16+
core ->> core: Run request
17+
```
18+
19+
## Writeable (single connection with HTTP2 and SSL)
20+
21+
Writeable session state protocol consists of a `POST` request that requires streaming over HTTP2 full-duplex.
22+
23+
```mermaid
24+
sequenceDiagram
25+
participant core as ASP.NET Core
26+
participant framework as ASP.NET
27+
participant session as Session Store
28+
core ->> +framework: POST /session
29+
framework ->> session: Request session
30+
session -->> framework: Session
31+
framework -->> core: Session
32+
core ->> core: Run request
33+
core ->> framework: Updated session state
34+
framework ->> session: Persist
35+
framework -->> -core: Persist result (JSON)
36+
```
37+
38+
## Writeable (two connections when HTTP2 or SSL are unavailable)
39+
40+
Writeable session state protocol starts with the the same as the readonly, but differs in the following:
41+
42+
- Requires an additional `PUT` request to update the state
43+
- The initial `GET` request must be kept open until the session is done; if closed, the session will not be able to be updated
44+
- A lock store (implemented internally as `ILockedSessionCache`) is used to track active open requests
45+
46+
```mermaid
47+
sequenceDiagram
48+
participant core as ASP.NET Core
49+
participant framework as ASP.NET
50+
participant store as Lock Store
51+
participant session as Session Store
52+
core ->> +framework: GET /session
53+
framework ->> session: Request session
54+
session -->> framework: Session
55+
framework -->> +store: Register active request
56+
framework -->> core: Session
57+
core ->> core: Run request
58+
core ->> +framework: PUT /session
59+
framework ->> store: Finalize session
60+
store ->> session: Persist
61+
store ->> -framework: Notify complete
62+
framework ->> -core: Persist complete
63+
framework -->> -core: Session complete
64+
```
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
using System.IO;
6+
using System.Net;
7+
using System.Net.Http;
8+
using System.Threading;
9+
using System.Threading.Tasks;
10+
using Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization;
11+
using Microsoft.Extensions.Logging;
12+
using Microsoft.Extensions.Options;
13+
using Microsoft.Net.Http.Headers;
14+
15+
namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.RemoteSession;
16+
17+
/// <summary>
18+
/// This is an implementation of <see cref="ISessionManager"/> that connects to an upstream session store.
19+
/// <list type="bullet">
20+
/// <item>If the request is readonly, it will close the remote connection and return the session state.</item>
21+
/// <item>If the request is not readonly, it will hold onto the remote connection and initiate a new connection to PUT the results.</item>
22+
/// </list>
23+
/// </summary>
24+
/// <remarks>
25+
/// For the non-readonly mode, it is preferrable to use <see cref="SingleConnectionWriteableRemoteAppSessionStateManager"/> instead
26+
/// which will only use a single connection via HTTP2 streaming.
27+
/// </remarks>
28+
internal sealed class DoubleConnectionRemoteAppSessionManager(
29+
ISessionSerializer serializer,
30+
IOptions<RemoteAppSessionStateClientOptions> options,
31+
IOptions<RemoteAppClientOptions> remoteAppClientOptions,
32+
ILogger<DoubleConnectionRemoteAppSessionManager> logger
33+
) : RemoteAppSessionStateManager(serializer, options, remoteAppClientOptions, logger)
34+
{
35+
public Task<ISessionState> GetReadOnlySessionStateAsync(HttpContextCore context) => CreateAsync(context, isReadOnly: true);
36+
37+
[System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", Justification = "They are either passed into another object or are manually disposed")]
38+
protected override async Task<ISessionState> GetSessionDataAsync(string? sessionId, bool readOnly, HttpContextCore callingContext, CancellationToken token)
39+
{
40+
// The request message is manually disposed at a later time
41+
var request = new HttpRequestMessage(HttpMethod.Get, Options.Path.Relative);
42+
43+
AddSessionCookieToHeader(request, sessionId);
44+
AddReadOnlyHeader(request, readOnly);
45+
46+
var response = await BackchannelClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token);
47+
48+
LogRetrieveResponse(response.StatusCode);
49+
50+
response.EnsureSuccessStatusCode();
51+
52+
var remoteSessionState = await Serializer.DeserializeAsync(await response.Content.ReadAsStreamAsync(token), token);
53+
54+
if (remoteSessionState is null)
55+
{
56+
throw new InvalidOperationException("Could not retrieve remote session state");
57+
}
58+
59+
// Propagate headers back to the caller since a new session ID may have been set
60+
// by the remote app if there was no session active previously or if the previous
61+
// session expired.
62+
PropagateHeaders(response, callingContext, HeaderNames.SetCookie);
63+
64+
if (remoteSessionState.IsReadOnly)
65+
{
66+
request.Dispose();
67+
response.Dispose();
68+
return remoteSessionState;
69+
}
70+
71+
return new RemoteSessionState(remoteSessionState, request, response, this);
72+
}
73+
74+
private sealed class SerializedSessionHttpContent : HttpContent
75+
{
76+
private readonly ISessionSerializer _serializer;
77+
private readonly ISessionState _state;
78+
79+
public SerializedSessionHttpContent(ISessionSerializer serializer, ISessionState state)
80+
{
81+
_serializer = serializer;
82+
_state = state;
83+
}
84+
85+
protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context)
86+
=> SerializeToStreamAsync(stream, context, default);
87+
88+
protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken)
89+
=> _serializer.SerializeAsync(_state, stream, cancellationToken);
90+
91+
protected override bool TryComputeLength(out long length)
92+
{
93+
length = 0;
94+
return false;
95+
}
96+
}
97+
98+
private sealed class RemoteSessionState(ISessionState other, HttpRequestMessage request, HttpResponseMessage response, DoubleConnectionRemoteAppSessionManager manager) : DelegatingSessionState
99+
{
100+
protected override ISessionState State => other;
101+
102+
protected override void Dispose(bool disposing)
103+
{
104+
base.Dispose(disposing);
105+
106+
if (disposing)
107+
{
108+
request.Dispose();
109+
response.Dispose();
110+
}
111+
}
112+
113+
public override async Task CommitAsync(CancellationToken token)
114+
{
115+
using var request = new HttpRequestMessage(HttpMethod.Put, manager.Options.Path.Relative)
116+
{
117+
Content = new SerializedSessionHttpContent(manager.Serializer, State)
118+
};
119+
120+
manager.AddSessionCookieToHeader(request, State.SessionID);
121+
122+
using var response = await manager.BackchannelClient.SendAsync(request, token);
123+
124+
manager.LogCommitResponse(response.StatusCode);
125+
126+
response.EnsureSuccessStatusCode();
127+
}
128+
}
129+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
using System.Net;
6+
using System.Net.Http;
7+
using System.Threading;
8+
using System.Threading.Tasks;
9+
using Microsoft.Extensions.Logging;
10+
using Microsoft.Extensions.Options;
11+
12+
namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.RemoteSession;
13+
14+
/// <summary>
15+
/// This is used to dispatch to either <see cref="DoubleConnectionRemoteAppSessionManager"/> if in read-only mode or <see cref="SingleConnectionWriteableRemoteAppSessionStateManager"/> if not.
16+
/// </summary>
17+
internal sealed partial class RemoteAppSessionDispatcher : ISessionManager
18+
{
19+
private readonly IOptions<RemoteAppSessionStateClientOptions> _options;
20+
private readonly ISessionManager _singleConnection;
21+
private readonly ISessionManager _doubleConnection;
22+
private readonly ILogger _logger;
23+
24+
/// <summary>
25+
/// 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
26+
/// in the DI container to achieve the same effect with just a constructor.
27+
/// </summary>
28+
public static ISessionManager Create(
29+
IOptions<RemoteAppSessionStateClientOptions> options,
30+
ISessionManager singleConnection,
31+
ISessionManager doubleConnection,
32+
ILogger logger
33+
)
34+
{
35+
return new RemoteAppSessionDispatcher(options, singleConnection, doubleConnection, logger);
36+
}
37+
38+
public RemoteAppSessionDispatcher(
39+
IOptions<RemoteAppSessionStateClientOptions> options,
40+
SingleConnectionWriteableRemoteAppSessionStateManager singleConnection,
41+
DoubleConnectionRemoteAppSessionManager doubleConnection,
42+
ILogger<RemoteAppSessionDispatcher> logger
43+
)
44+
: this(options, (ISessionManager)singleConnection, doubleConnection, logger)
45+
{
46+
}
47+
48+
private RemoteAppSessionDispatcher(
49+
IOptions<RemoteAppSessionStateClientOptions> options,
50+
ISessionManager singleConnection,
51+
ISessionManager doubleConnection,
52+
ILogger logger)
53+
{
54+
_options = options;
55+
_singleConnection = singleConnection;
56+
_doubleConnection = doubleConnection;
57+
_logger = logger;
58+
}
59+
60+
public async Task<ISessionState> CreateAsync(HttpContextCore context, SessionAttribute metadata)
61+
{
62+
if (metadata.IsReadOnly)
63+
{
64+
// In readonly mode it's a simple GET request
65+
return await _doubleConnection.CreateAsync(context, metadata);
66+
}
67+
68+
if (_options.Value.UseSingleConnection)
69+
{
70+
try
71+
{
72+
return await _singleConnection.CreateAsync(context, metadata);
73+
}
74+
75+
// We can attempt to discover if the server supports the single connection. If it doesn't,
76+
// future attempts will fallback to the double until the option value is reset.
77+
catch (HttpRequestException ex) when (ServerDoesNotSupportSingleConnection(ex))
78+
{
79+
LogServerDoesNotSupportSingleConnection(ex);
80+
_options.Value.UseSingleConnection = false;
81+
}
82+
catch (Exception ex)
83+
{
84+
LogServerFailedSingelConnection(ex);
85+
throw;
86+
}
87+
}
88+
89+
return await _doubleConnection.CreateAsync(context, metadata);
90+
}
91+
92+
private static bool ServerDoesNotSupportSingleConnection(HttpRequestException ex)
93+
{
94+
#if NET8_0_OR_GREATER
95+
// This is thrown when HTTP2 cannot be initiated
96+
if (ex.HttpRequestError == HttpRequestError.HttpProtocolError)
97+
{
98+
return true;
99+
}
100+
#endif
101+
102+
// This is thrown if the server does not know about the POST verb
103+
return ex.StatusCode == HttpStatusCode.MethodNotAllowed;
104+
}
105+
106+
[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.")]
107+
private partial void LogServerDoesNotSupportSingleConnection(HttpRequestException ex);
108+
109+
[LoggerMessage(1, LogLevel.Error, "Failed to connect to server with an unknown reason")]
110+
private partial void LogServerFailedSingelConnection(Exception ex);
111+
}

src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/RemoteAppSessionStateClientOptions.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ public PathString SessionEndpointPath
1515
set => Path = new(value);
1616
}
1717

18+
/// <summary>
19+
/// 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>.
20+
/// </summary>
21+
public bool UseSingleConnection { get; set; } = true;
22+
1823
internal RelativePathString Path { get; private set; } = new(SessionConstants.SessionEndpointPath);
1924

2025
/// <summary>

src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/RemoteAppSessionStateExtensions.cs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,35 @@
44
using System;
55
using Microsoft.AspNetCore.SystemWebAdapters;
66
using Microsoft.AspNetCore.SystemWebAdapters.SessionState.RemoteSession;
7+
using Microsoft.Extensions.Logging;
8+
using Microsoft.Extensions.Options;
79

810
namespace Microsoft.Extensions.DependencyInjection;
911

10-
public static class RemoteAppSessionStateExtensions
12+
public static partial class RemoteAppSessionStateExtensions
1113
{
14+
[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.")]
15+
private static partial void LogSingleConnectionDisabled(ILogger logger);
16+
1217
public static ISystemWebAdapterRemoteClientAppBuilder AddSessionClient(this ISystemWebAdapterRemoteClientAppBuilder builder, Action<RemoteAppSessionStateClientOptions>? configure = null)
1318
{
1419
ArgumentNullException.ThrowIfNull(builder);
1520

16-
builder.Services.AddTransient<ISessionManager, RemoteAppSessionStateManager>();
21+
builder.Services.AddTransient<DoubleConnectionRemoteAppSessionManager>();
22+
builder.Services.AddTransient<SingleConnectionWriteableRemoteAppSessionStateManager>();
23+
builder.Services.AddTransient<ISessionManager, RemoteAppSessionDispatcher>();
1724

1825
builder.Services.AddOptions<RemoteAppSessionStateClientOptions>()
1926
.Configure(configure ?? (_ => { }))
27+
.PostConfigure<IOptions<RemoteAppClientOptions>, ILogger<RemoteAppClientOptions>>((options, remote, logger) =>
28+
{
29+
// The single connection remote app session client requires https to work so if that's not the case, we'll disable it
30+
if (options.UseSingleConnection && !string.Equals(remote.Value.RemoteAppUrl.Scheme, "https", StringComparison.OrdinalIgnoreCase))
31+
{
32+
LogSingleConnectionDisabled(logger);
33+
options.UseSingleConnection = false;
34+
}
35+
})
2036
.ValidateDataAnnotations();
2137

2238
return builder;

0 commit comments

Comments
 (0)