diff --git a/designs/session-serialization.md b/designs/session-serialization.md new file mode 100644 index 000000000..ed84e7879 --- /dev/null +++ b/designs/session-serialization.md @@ -0,0 +1,106 @@ +# Session serialization + +Session serialization is provided through the `ISessionSerializer` type. There are two modes that are available: + +> [!NOTE] +> The bit offsets are general guidelines here to show the layout. The diagram tool does not have an option to turn it off at the moment. See the descriptions for details on bit-length + +## Common structure + +```mermaid +packet-beta +0: "M" +1-10: "Session Id (Variable length)" +11: "N" +12: "A" +13: "R" +14-17: "T" +18-21: "C" +22-31: "Key 1 Blob" +32-39: "Key 2 Blob" +40-48: "..." +49-59: "Flags (variable)" +``` + +Where: +- `M`: Mode `byte` +- `N`: New session `bool` +- `A`: Abandoned `bool` +- `R`: Readonly `bool` +- `T`: Timeout `7-bit encoded int` +- `C`: Key count `7-bit encoded int` + +## Flags + +Flags allow for additional information to be sent either direction that may not be known initially. This field was added v2 but is backwards compatible with the v1 deserializer and will operate as a no-op as it just reads the things it knows about and doesn't look for the end of a payload. + +Structure: + +```mermaid +packet-beta +0: "C" +1: "F1" +2: "F1L" +3-10: "Flag1 specific payload" +11: "F2" +12: "F2L" +13-20: "Flag2 specific payload" +21-25: "..." +``` + +Where: +- `Fn`: Flag `n` +- `C`: Flag count `7-bit encoded int` +- `Fn`: Custom identifier `7-bit encoded int` +- `FnL`: Flag payload (type determined by `Fn`) + +An example is the flag section used to indicate that there is support for diffing a session state on the server: + +```mermaid +packet-beta +0: "1" +1: "100" +2: "0" +``` + +## Unknown keys + +If the unknown keys array is included, it has the following pattern: + +```mermaid +packet-beta +0: "C" +1-11: "Key1" +12-20: "Key2" +21-23: "..." +24-31: "KeyN" +``` + +Where: + +- `C` is the count *(Note: 7-bit encoded int)* + +## Full Copy (Mode = 1) + +The following is the structure of the key blobs when the full state is serialized: + +```mermaid +packet-beta +0-10: "Key name" +11-20: "Serialized value" +``` + +## Diffing Support (Mode = 2) + +The following is the structure of the key blobs when only the difference is serialized: + +```mermaid +packet-beta +0-10: "Key name" +11: "S" +12-20: "Serialized value" +``` + +Where: +- *S*: A value indicating the change the key has undergone from the values in `SessionItemChangeState` + diff --git a/samples/RemoteSession/RemoteSessionCore/appsettings.json b/samples/RemoteSession/RemoteSessionCore/appsettings.json index 406d5fe1d..cef53ea90 100644 --- a/samples/RemoteSession/RemoteSessionCore/appsettings.json +++ b/samples/RemoteSession/RemoteSessionCore/appsettings.json @@ -2,7 +2,8 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Warning", + "Microsoft.AspNetCore.SystemWebAdapters.SessionState": "Trace" } }, "AllowedHosts": "*", @@ -10,5 +11,4 @@ "Key": "23EB1AEF-E019-4850-A257-3DB3A85495BD", "Url": "https://localhost:44305" } - } diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/SessionSerializerOptions.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/SessionSerializerOptions.cs index 1ac31c160..61de2b387 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/SessionSerializerOptions.cs +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/SessionSerializerOptions.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/DoubleConnectionRemoteAppSessionManager.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/DoubleConnectionRemoteAppSessionManager.cs index 5fd895751..c4b09045a 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/DoubleConnectionRemoteAppSessionManager.cs +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/DoubleConnectionRemoteAppSessionManager.cs @@ -41,7 +41,7 @@ protected override async Task GetSessionDataAsync(string? session var request = new HttpRequestMessage(HttpMethod.Get, Options.Path.Relative); AddSessionCookieToHeader(request, sessionId); - AddReadOnlyHeader(request, readOnly); + AddRemoteSessionHeaders(request, readOnly); var response = await BackchannelClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token); @@ -71,22 +71,19 @@ protected override async Task GetSessionDataAsync(string? session return new RemoteSessionState(remoteSessionState, request, response, this); } - private sealed class SerializedSessionHttpContent : HttpContent + private sealed class SerializedSessionHttpContent( + ISessionSerializer serializer, + ISessionState state, + SessionSerializerContext context + ) : 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 Task SerializeToStreamAsync(Stream stream, TransportContext? _, CancellationToken cancellationToken) + { + return serializer.SerializeAsync(state, context, stream, cancellationToken); + } protected override bool TryComputeLength(out long length) { @@ -95,7 +92,12 @@ protected override bool TryComputeLength(out long length) } } - private sealed class RemoteSessionState(ISessionState other, HttpRequestMessage request, HttpResponseMessage response, DoubleConnectionRemoteAppSessionManager manager) : DelegatingSessionState + private sealed class RemoteSessionState( + ISessionState other, + HttpRequestMessage request, + HttpResponseMessage response, + DoubleConnectionRemoteAppSessionManager manager + ) : DelegatingSessionState { protected override ISessionState State => other; @@ -112,18 +114,19 @@ protected override void Dispose(bool disposing) public override async Task CommitAsync(CancellationToken token) { + var sessionContext = manager.GetSupportedSerializerContext(response); using var request = new HttpRequestMessage(HttpMethod.Put, manager.Options.Path.Relative) { - Content = new SerializedSessionHttpContent(manager.Serializer, State) + Content = new SerializedSessionHttpContent(manager.Serializer, State, sessionContext) }; manager.AddSessionCookieToHeader(request, State.SessionID); - using var response = await manager.BackchannelClient.SendAsync(request, token); + using var result = await manager.BackchannelClient.SendAsync(request, token); - manager.LogCommitResponse(response.StatusCode); + manager.LogCommitResponse(result.StatusCode); - response.EnsureSuccessStatusCode(); + result.EnsureSuccessStatusCode(); } } } diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/RemoteAppSessionDispatcher.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/RemoteAppSessionDispatcher.cs index 448a81a8b..ffb1efe13 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/RemoteAppSessionDispatcher.cs +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/RemoteAppSessionDispatcher.cs @@ -76,7 +76,7 @@ public async Task CreateAsync(HttpContextCore context, SessionAtt // future attempts will fallback to the double until the option value is reset. catch (HttpRequestException ex) when (ServerDoesNotSupportSingleConnection(ex)) { - LogServerDoesNotSupportSingleConnection(ex); + LogServerDoesNotSupportSingleConnection(); _options.Value.UseSingleConnection = false; } catch (Exception ex) @@ -104,7 +104,7 @@ private static bool ServerDoesNotSupportSingleConnection(HttpRequestException ex } [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); + private partial void LogServerDoesNotSupportSingleConnection(); [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/RemoteAppSessionStateManager.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/RemoteAppSessionStateManager.cs index a5fe6f8d3..d216038ab 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/RemoteAppSessionStateManager.cs +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/RemoteAppSessionStateManager.cs @@ -3,6 +3,7 @@ using System; using System.Buffers; +using System.Globalization; using System.IO; using System.Linq; using System.Net; @@ -53,6 +54,9 @@ protected RemoteAppSessionStateManager( [LoggerMessage(EventId = 3, Level = LogLevel.Trace, Message = "Received {StatusCode} response committing remote session state")] protected partial void LogCommitResponse(HttpStatusCode statusCode); + [LoggerMessage(EventId = 4, Level = LogLevel.Trace, Message = "Server supports version {Version} for serializing")] + protected partial void LogServerVersionSupport(byte version); + public Task CreateAsync(HttpContextCore context, SessionAttribute metadata) => CreateAsync(context, metadata.IsReadOnly); @@ -97,6 +101,24 @@ protected void AddSessionCookieToHeader(HttpRequestMessage req, string? sessionI } } - protected static void AddReadOnlyHeader(HttpRequestMessage req, bool readOnly) - => req.Headers.Add(SessionConstants.ReadOnlyHeaderName, readOnly.ToString()); + protected static void AddRemoteSessionHeaders(HttpRequestMessage req, bool readOnly) + { + if (readOnly) + { + req.Headers.Add(SessionConstants.ReadOnlyHeaderName, readOnly.ToString()); + } + + req.Headers.Add(SessionConstants.SupportedVersion, SessionSerializerContext.Latest.SupportedVersion.ToString(CultureInfo.InvariantCulture)); + } + + protected SessionSerializerContext GetSupportedSerializerContext(HttpResponseMessage message) + { + var context = message.Headers.TryGetValues(SessionConstants.SupportedVersion, out var versions) + ? SessionSerializerContext.Parse(versions) + : SessionSerializerContext.Default; + + LogServerVersionSupport(context.SupportedVersion); + + return context; + } } diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/SingleConnectionWriteableRemoteAppSessionStateManager.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/SingleConnectionWriteableRemoteAppSessionStateManager.cs index 58538cc2f..ad5a60163 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/SingleConnectionWriteableRemoteAppSessionStateManager.cs +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/SingleConnectionWriteableRemoteAppSessionStateManager.cs @@ -49,7 +49,7 @@ protected override async Task GetSessionDataAsync(string? session }; AddSessionCookieToHeader(request, sessionId); - AddReadOnlyHeader(request, readOnly); + AddRemoteSessionHeaders(request, readOnly); var response = await BackchannelClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token); @@ -79,7 +79,7 @@ protected override async Task GetSessionDataAsync(string? session // session expired. PropagateHeaders(response, callingContext, HeaderNames.SetCookie); - return new RemoteSessionState(remoteSessionState, request, response, content, responseStream); + return new RemoteSessionState(remoteSessionState, request, response, GetSupportedSerializerContext(response), content, responseStream); } [JsonSerializable(typeof(SessionPostResult))] @@ -89,7 +89,7 @@ private sealed partial class SessionPostResultContext : JsonSerializerContext private sealed class CommittingSessionHttpContent : HttpContent { - private readonly TaskCompletionSource _state; + private readonly TaskCompletionSource<(ISessionState, SessionSerializerContext)> _state; public CommittingSessionHttpContent(ISessionSerializer serializer) { @@ -99,7 +99,7 @@ public CommittingSessionHttpContent(ISessionSerializer serializer) public ISessionSerializer Serializer { get; } - public void Commit(ISessionState state) => _state.SetResult(state); + public void Commit(SessionSerializerContext context, ISessionState state) => _state.SetResult((state, context)); protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => SerializeToStreamAsync(stream, context, default); @@ -107,8 +107,9 @@ protected override Task SerializeToStreamAsync(Stream stream, TransportContext? 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); + var (state, sessionContext) = await _state.Task; + + await Serializer.SerializeAsync(state, sessionContext, stream, cancellationToken); } protected override bool TryComputeLength(out long length) @@ -118,7 +119,15 @@ protected override bool TryComputeLength(out long length) } } - private sealed class RemoteSessionState(ISessionState other, HttpRequestMessage request, HttpResponseMessage response, CommittingSessionHttpContent content, Stream stream) : DelegatingSessionState + + private sealed class RemoteSessionState( + ISessionState other, + HttpRequestMessage request, + HttpResponseMessage response, + SessionSerializerContext sessionContext, + CommittingSessionHttpContent content, + Stream stream + ) : DelegatingSessionState { protected override ISessionState State => other; @@ -137,7 +146,7 @@ protected override void Dispose(bool disposing) public override async Task CommitAsync(CancellationToken token) { - content.Commit(State); + content.Commit(sessionContext, State); var result = await JsonSerializer.DeserializeAsync(stream, SessionPostResultContext.Default.SessionPostResult, token); diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/GetWriteableSessionHandler.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/GetWriteableSessionHandler.cs index a3308ae31..bcf63d168 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/GetWriteableSessionHandler.cs +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/GetWriteableSessionHandler.cs @@ -10,7 +10,7 @@ namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.RemoteSession; -internal sealed class GetWriteableSessionHandler : HttpTaskAsyncHandler, IRequiresSessionState +internal sealed class GetWriteableSessionHandler : VersionedSessionHandler, IRequiresSessionState { private const byte EndOfFrame = (byte)'\n'; @@ -24,17 +24,7 @@ public GetWriteableSessionHandler(ISessionSerializer serializer, ILockedSessionC _cache = cache; } - 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); - - await ProcessRequestAsync(new HttpContextWrapper(context), cts.Token).ConfigureAwait(false); - - context.ApplicationInstance.CompleteRequest(); - } - - public async Task ProcessRequestAsync(HttpContextBase context, CancellationToken token) + public override async Task ProcessRequestAsync(HttpContextBase context, SessionSerializerContext sessionContext, CancellationToken token) { // If session data is retrieved exclusively, then it needs sent to the client and // this request needs to remain open while waiting for the client to either send updates @@ -50,7 +40,7 @@ public async Task ProcessRequestAsync(HttpContextBase context, CancellationToken using var wrapper = new HttpSessionStateBaseWrapper(context.Session); - await _serializer.SerializeAsync(wrapper, context.Response.OutputStream, token); + await _serializer.SerializeAsync(wrapper, sessionContext, context.Response.OutputStream, token); // Delimit the json body with a new line to mark the end of content context.Response.OutputStream.WriteByte(EndOfFrame); diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/InMemoryLockedSessions.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/InMemoryLockedSessions.cs index a45f35c11..280d3b009 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/InMemoryLockedSessions.cs +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/InMemoryLockedSessions.cs @@ -8,17 +8,20 @@ using System.Threading.Tasks; using System.Web; using Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; +using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.RemoteSession; internal class InMemoryLockedSessions : ILockedSessionCache { + private readonly ILogger _logger; private readonly ISessionSerializer _serializer; private readonly ConcurrentDictionary _cache = new(); - public InMemoryLockedSessions(ISessionSerializer serializer) + public InMemoryLockedSessions(ISessionSerializer serializer, ILogger logger) { _serializer = serializer; + _logger = logger; } public IDisposable Register(HttpSessionStateBase session, Action callback) @@ -48,7 +51,7 @@ public async Task SaveAsync(string sessionId, Stream stream, return SessionSaveResult.DeserializationError; } - result.CopyTo(session); + result.CopyTo(_logger, session); return SessionSaveResult.Success; } diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/ReadOnlySessionHandler.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/ReadOnlySessionHandler.cs index e06e59755..9d14ea14e 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/ReadOnlySessionHandler.cs +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/ReadOnlySessionHandler.cs @@ -8,7 +8,7 @@ namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.RemoteSession; -internal sealed class ReadOnlySessionHandler : HttpTaskAsyncHandler, IReadOnlySessionState +internal sealed class ReadOnlySessionHandler : VersionedSessionHandler, IReadOnlySessionState { private readonly ISessionSerializer _serializer; @@ -19,19 +19,13 @@ public ReadOnlySessionHandler(ISessionSerializer serializer) _serializer = serializer; } - public override async Task ProcessRequestAsync(HttpContext context) - { - await ProcessRequestAsync(new HttpContextWrapper(context)); - context.ApplicationInstance.CompleteRequest(); - } - - public async Task ProcessRequestAsync(HttpContextBase context) + public override async Task ProcessRequestAsync(HttpContextBase context, SessionSerializerContext sessionContext, CancellationToken token) { context.Response.ContentType = "application/json; charset=utf-8"; context.Response.StatusCode = 200; using var wrapper = new HttpSessionStateBaseWrapper(context.Session); - await _serializer.SerializeAsync(wrapper, context.Response.OutputStream, context.Response.ClientDisconnectedToken); + await _serializer.SerializeAsync(wrapper, sessionContext, context.Response.OutputStream, context.Response.ClientDisconnectedToken); } } diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/ReadWriteSessionHandler.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/ReadWriteSessionHandler.cs index 2547ed886..658554ab7 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/ReadWriteSessionHandler.cs +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/ReadWriteSessionHandler.cs @@ -6,40 +6,36 @@ using System.Web; using System.Web.SessionState; using Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; +using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.RemoteSession; -internal sealed partial class ReadWriteSessionHandler : HttpTaskAsyncHandler, IRequiresSessionState, IRequireBufferlessStream +internal sealed partial class ReadWriteSessionHandler : VersionedSessionHandler, IRequiresSessionState, IRequireBufferlessStream { private readonly ISessionSerializer _serializer; + private readonly ILogger _logger; - public ReadWriteSessionHandler(ISessionSerializer serializer) + public ReadWriteSessionHandler(ISessionSerializer serializer, ILogger logger) { _serializer = serializer; + _logger = logger; } - public override async Task ProcessRequestAsync(HttpContext context) + public override async Task ProcessRequestAsync(HttpContextBase context, SessionSerializerContext sessionContext, CancellationToken token) { - using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(context.Session.Timeout)); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, context.Response.ClientDisconnectedToken); + await SendSessionAsync(context, sessionContext, token).ConfigureAwait(false); - var contextWrapper = new HttpContextWrapper(context); - - await SendSessionAsync(contextWrapper, cts.Token).ConfigureAwait(false); - - if (await RetrieveUpdatedSessionAsync(contextWrapper, cts.Token)) + if (await RetrieveUpdatedSessionAsync(context, token)) { - await SendSessionWriteResultAsync(contextWrapper.Response, Results.Succeeded, cts.Token); + await SendSessionWriteResultAsync(context.Response, Results.Succeeded, token); } else { - await SendSessionWriteResultAsync(contextWrapper.Response, Results.NoSessionData, cts.Token); + await SendSessionWriteResultAsync(context.Response, Results.NoSessionData, token); } - - context.ApplicationInstance.CompleteRequest(); } - private async Task SendSessionAsync(HttpContextBase context, CancellationToken token) + private async Task SendSessionAsync(HttpContextBase context, SessionSerializerContext sessionContext, CancellationToken token) { // Send the initial snapshot of session data context.Response.ContentType = "text/event-stream"; @@ -47,9 +43,10 @@ private async Task SendSessionAsync(HttpContextBase context, CancellationToken t using var wrapper = new HttpSessionStateBaseWrapper(context.Session); - await _serializer.SerializeAsync(wrapper, context.Response.OutputStream, token); + await _serializer.SerializeAsync(wrapper, sessionContext, context.Response.OutputStream, token); // Ensure to call HttpResponse.FlushAsync to flush the request itself, and not context.Response.OutputStream.FlushAsync() + await context.Response.OutputStream.FlushAsync(token); await context.Response.FlushAsync(); } @@ -62,7 +59,7 @@ private async Task RetrieveUpdatedSessionAsync(HttpContextBase context, Ca if (deserialized is { }) { - deserialized.CopyTo(context.Session); + deserialized.CopyTo(_logger, context.Session); return true; } else diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/RemoteSessionModule.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/RemoteSessionModule.cs index aae8ccefe..aecb11f99 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/RemoteSessionModule.cs +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/RemoteSessionModule.cs @@ -3,13 +3,14 @@ using System.Web; using Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.RemoteSession; internal sealed class RemoteSessionModule : RemoteModule { - public RemoteSessionModule(IOptions sessionOptions, IOptions remoteAppOptions, ILockedSessionCache cache, ISessionSerializer serializer) + public RemoteSessionModule(IOptions sessionOptions, IOptions remoteAppOptions, ILoggerFactory loggerFactory, ILockedSessionCache cache, ISessionSerializer serializer) : base(remoteAppOptions) { if (sessionOptions is null) @@ -23,12 +24,13 @@ public RemoteSessionModule(IOptions sessionO var readonlyHandler = new ReadOnlySessionHandler(serializer); var writeableHandler = new GetWriteableSessionHandler(serializer, cache); - var persistHandler = new ReadWriteSessionHandler(serializer); + var persistHandler = new ReadWriteSessionHandler(serializer, loggerFactory.CreateLogger()); var saveHandler = new StoreSessionStateHandler(cache, options.CookieName); MapGet(context => GetIsReadonly(context.Request) ? readonlyHandler : writeableHandler); + MapPut(context => saveHandler); - MapPost(_ => persistHandler); + MapPost(context => persistHandler); static bool GetIsReadonly(HttpRequestBase request) => bool.TryParse(request.Headers.Get(SessionConstants.ReadOnlyHeaderName), out var result) && result; diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/VersionedSessionHandler.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/VersionedSessionHandler.cs new file mode 100644 index 000000000..cacb2b6d2 --- /dev/null +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/VersionedSessionHandler.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using System.Web; +using System.Web.SessionState; +using Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; + +namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.RemoteSession; + +internal abstract class VersionedSessionHandler : HttpTaskAsyncHandler +{ + public sealed 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); + + context.Response.Headers.Add(SessionConstants.SupportedVersion, SessionSerializerContext.Latest.SupportedVersion.ToString(CultureInfo.InvariantCulture)); + + var sessionContext = SessionSerializerContext.Parse(context.Request.Headers.Get(SessionConstants.SupportedVersion)); + await ProcessRequestAsync(new HttpContextWrapper(context), sessionContext, cts.Token).ConfigureAwait(false); + + context.ApplicationInstance.CompleteRequest(); + } + + public abstract Task ProcessRequestAsync(HttpContextBase context, SessionSerializerContext sessionContext, CancellationToken token); +} + diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/SessionStateExtensions.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/SessionStateExtensions.cs index 05c3242d7..b32ae31b8 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/SessionStateExtensions.cs +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/SessionStateExtensions.cs @@ -3,12 +3,17 @@ using System; using System.Web; +using Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; +using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.RemoteSession; -internal static class SessionStateExtensions +internal static partial class SessionStateExtensions { - public static void CopyTo(this ISessionState result, HttpSessionStateBase state) + [LoggerMessage(0, LogLevel.Warning, "Unknown session key '{KeyName}' was received.")] + private static partial void LogUnknownSessionKey(ILogger logger, string keyName); + + public static void CopyTo(this ISessionState result, ILogger logger, HttpSessionStateBase state) { if (!string.Equals(state.SessionID, result.SessionID, StringComparison.Ordinal)) { @@ -22,11 +27,43 @@ public static void CopyTo(this ISessionState result, HttpSessionStateBase state) } state.Timeout = result.Timeout; + + if (result is ISessionStateChangeset changes) + { + UpdateFromChanges(changes, logger, state); + } + else + { + Replace(result, state); + } + } + + private static void UpdateFromChanges(ISessionStateChangeset from, ILogger logger, HttpSessionStateBase state) + { + foreach (var change in from.Changes) + { + if (change.State is SessionItemChangeState.Changed or SessionItemChangeState.New) + { + state[change.Key] = from[change.Key]; + } + else if (change.State is SessionItemChangeState.Removed) + { + state.Remove(change.Key); + } + else if (change.State is SessionItemChangeState.Unknown) + { + LogUnknownSessionKey(logger, change.Key); + } + } + } + + private static void Replace(ISessionState from, HttpSessionStateBase state) + { state.Clear(); - foreach (var key in result.Keys) + foreach (var key in from.Keys) { - state[key] = result[key]; + state[key] = from[key]; } } } diff --git a/src/Services/SessionState/BinarySessionChangesetSerializer.cs b/src/Services/SessionState/BinarySessionChangesetSerializer.cs new file mode 100644 index 000000000..95c880b1c --- /dev/null +++ b/src/Services/SessionState/BinarySessionChangesetSerializer.cs @@ -0,0 +1,133 @@ +// 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.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; + +internal readonly struct BinarySessionChangesetSerializer(ISessionKeySerializer serializer) +{ + private static class PayloadKind + { + // V2 + public const byte Value = 1; + public const byte Removed = 2; + + // Used to mark the end of the payload + public const byte EndSentinel = 0xFF; + } + + public void Write(ISessionState state, BinaryWriter writer) + { + writer.Write(BinarySessionSerializer.Version2); + writer.Write(state.SessionID); + + writer.Write(state.IsNewSession); + writer.Write(state.IsAbandoned); + writer.Write(state.IsReadOnly); + + writer.Write7BitEncodedInt(state.Timeout); + + foreach (var key in state.Keys) + { + if (serializer.TrySerialize(key, state[key], out var result)) + { + writer.Write(PayloadKind.Value); + writer.Write(key); + writer.Write7BitEncodedInt(result.Length); + writer.Write(result); + } + } + + writer.Write(PayloadKind.EndSentinel); + } + + public void Write(ISessionStateChangeset state, BinaryWriter writer) + { + writer.Write(BinarySessionSerializer.Version2); + writer.Write(state.SessionID); + + writer.Write(state.IsNewSession); + writer.Write(state.IsAbandoned); + writer.Write(state.IsReadOnly); + + writer.Write7BitEncodedInt(state.Timeout); + + foreach (var item in state.Changes) + { + if (item.State is SessionItemChangeState.NoChange) + { + continue; + } + else if (item.State is SessionItemChangeState.Removed) + { + writer.Write(PayloadKind.Removed); + writer.Write(item.Key); + } + else if (item.State is SessionItemChangeState.New or SessionItemChangeState.Changed && serializer.TrySerialize(item.Key, state[item.Key], out var result)) + { + writer.Write(PayloadKind.Value); + writer.Write(item.Key); + writer.Write7BitEncodedInt(result.Length); + writer.Write(result); + } + } + + writer.Write(PayloadKind.EndSentinel); + } + + public SessionStateCollection Read(BinaryReader reader) + { + var state = SessionStateCollection.CreateTracking(serializer); + + state.SessionID = reader.ReadString(); + state.IsNewSession = reader.ReadBoolean(); + state.IsAbandoned = reader.ReadBoolean(); + state.IsReadOnly = reader.ReadBoolean(); + state.Timeout = reader.Read7BitEncodedInt(); + + while (true) + { + var kind = reader.ReadByte(); + + if (kind == PayloadKind.EndSentinel) + { + break; + } + + if (kind is PayloadKind.Removed) + { + var key = reader.ReadString(); + state.MarkRemoved(key); + } + else if (kind is PayloadKind.Value) + { + var key = reader.ReadString(); + var length = reader.Read7BitEncodedInt(); + var bytes = reader.ReadBytes(length); + + if (serializer.TryDeserialize(key, bytes, out var result)) + { + if (result is not null) + { + state[key] = result; + } + } + } + else + { + throw new InvalidOperationException($"Unknown session serialization kind '{kind}'"); + } + } + + return state; + } +} diff --git a/src/Services/SessionState/BinarySessionSerializer.cs b/src/Services/SessionState/BinarySessionSerializer.cs index 2d7481075..6a0d27299 100644 --- a/src/Services/SessionState/BinarySessionSerializer.cs +++ b/src/Services/SessionState/BinarySessionSerializer.cs @@ -16,74 +16,24 @@ namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; [System.Diagnostics.CodeAnalysis.SuppressMessage("Maintainability", "CA1510:Use ArgumentNullException throw helper", Justification = "Source shared with .NET Framework that does not have the method")] internal partial class BinarySessionSerializer : ISessionSerializer { - private const byte Version = 1; + internal const byte Version1 = 1; + internal const byte Version2 = 2; - private readonly SessionSerializerOptions _options; + private readonly IOptions _options; private readonly ISessionKeySerializer _serializer; private readonly ILogger _logger; + private readonly BinarySessionStateSerializer V1Serializer; + private readonly BinarySessionChangesetSerializer V2Serializer; + public BinarySessionSerializer(ICompositeSessionKeySerializer serializer, IOptions options, ILogger logger) { _serializer = serializer; - _options = options.Value; + _options = options; _logger = logger; - } - - [LoggerMessage(EventId = 0, Level = LogLevel.Warning, Message = "Could not serialize unknown session key '{Key}'")] - partial void LogSerialization(string key); - - [LoggerMessage(EventId = 1, Level = LogLevel.Warning, Message = "Could not deserialize unknown session key '{Key}'")] - partial void LogDeserialization(string key); - - public void Write(ISessionState state, BinaryWriter writer) - { - writer.Write(Version); - writer.Write(state.SessionID); - - writer.Write(state.IsNewSession); - writer.Write(state.IsAbandoned); - writer.Write(state.IsReadOnly); - - writer.Write7BitEncodedInt(state.Timeout); - writer.Write7BitEncodedInt(state.Count); - - List? unknownKeys = null; - - foreach (var item in state.Keys) - { - writer.Write(item); - - if (_serializer.TrySerialize(item, state[item], out var result)) - { - writer.Write7BitEncodedInt(result.Length); - writer.Write(result); - } - else - { - (unknownKeys ??= new()).Add(item); - writer.Write7BitEncodedInt(0); - } - } - - if (unknownKeys is null) - { - writer.Write7BitEncodedInt(0); - } - else - { - writer.Write7BitEncodedInt(unknownKeys.Count); - - foreach (var key in unknownKeys) - { - LogSerialization(key); - writer.Write(key); - } - } - if (unknownKeys is not null && _options.ThrowOnUnknownSessionKey) - { - throw new UnknownSessionKeyException(unknownKeys); - } + V1Serializer = new BinarySessionStateSerializer(serializer); + V2Serializer = new BinarySessionChangesetSerializer(serializer); } public ISessionState Read(BinaryReader reader) @@ -93,29 +43,17 @@ public ISessionState Read(BinaryReader reader) throw new ArgumentNullException(nameof(reader)); } - if (reader.ReadByte() != Version) - { - throw new InvalidOperationException("Serialized session state has different version than expected"); - } - - var state = new BinaryReaderSerializedSessionState(reader, _serializer); + var version = reader.ReadByte(); - if (state.UnknownKeys is { Count: > 0 } unknownKeys) + return version switch { - foreach (var unknown in unknownKeys) - { - LogDeserialization(unknown); - } - - if (_options.ThrowOnUnknownSessionKey) - { - throw new UnknownSessionKeyException(unknownKeys); - } - } - - return state; + Version1 => V1Serializer.Read(reader), + Version2 => V2Serializer.Read(reader), + _ => throw new InvalidOperationException("Serialized session state has unknown version.") + }; } + public Task DeserializeAsync(Stream stream, CancellationToken token) { using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: true); @@ -123,93 +61,30 @@ public ISessionState Read(BinaryReader reader) return Task.FromResult(Read(reader)); } - public Task SerializeAsync(ISessionState state, Stream stream, CancellationToken token) + public Task SerializeAsync(ISessionState state, SessionSerializerContext context, Stream stream, CancellationToken token) { using var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true); - Write(state, writer); - - return Task.CompletedTask; - } - - private class BinaryReaderSerializedSessionState : ISessionState - { - public BinaryReaderSerializedSessionState(BinaryReader reader, ISessionKeySerializer serializer) + if (context.SupportedVersion == 1) { - SessionID = reader.ReadString(); - IsNewSession = reader.ReadBoolean(); - IsAbandoned = reader.ReadBoolean(); - IsReadOnly = reader.ReadBoolean(); - Timeout = reader.Read7BitEncodedInt(); - - var count = reader.Read7BitEncodedInt(); - - for (var index = count; index > 0; index--) + V1Serializer.Write(state, writer); + } + else if (context.SupportedVersion == 2) + { + if (state is ISessionStateChangeset changes) { - var key = reader.ReadString(); - var length = reader.Read7BitEncodedInt(); - var bytes = reader.ReadBytes(length); - - if (serializer.TryDeserialize(key, bytes, out var result)) - { - if (result is not null) - { - this[key] = result; - } - } - else - { - (UnknownKeys ??= new()).Add(key); - } + V2Serializer.Write(changes, writer); } - - var unknown = reader.Read7BitEncodedInt(); - - if (unknown > 0) + else { - for (var index = unknown; index > 0; index--) - { - (UnknownKeys ??= new()).Add(reader.ReadString()); - } + V2Serializer.Write(state, writer); } } - - private Dictionary? _items; - - public object? this[string key] + else { - get => _items?.TryGetValue(key, out var result) is true ? result : null; - set => (_items ??= new())[key] = value; + throw new InvalidOperationException($"Unsupported serialization version '{context.SupportedVersion}"); } - internal List? UnknownKeys { get; private set; } - - public string SessionID { get; set; } = null!; - - public bool IsReadOnly { get; set; } - - public int Timeout { get; set; } - - public bool IsNewSession { get; set; } - - public int Count => _items?.Count ?? 0; - - public bool IsAbandoned { get; set; } - - bool ISessionState.IsSynchronized => false; - - object ISessionState.SyncRoot => this; - - IEnumerable ISessionState.Keys => _items?.Keys ?? Enumerable.Empty(); - - void ISessionState.Clear() => _items?.Clear(); - - void ISessionState.Remove(string key) => _items?.Remove(key); - - Task ISessionState.CommitAsync(CancellationToken token) => Task.CompletedTask; - - void IDisposable.Dispose() - { - } + return Task.CompletedTask; } } diff --git a/src/Services/SessionState/BinarySessionStateSerializer.cs b/src/Services/SessionState/BinarySessionStateSerializer.cs new file mode 100644 index 000000000..bad9e4301 --- /dev/null +++ b/src/Services/SessionState/BinarySessionStateSerializer.cs @@ -0,0 +1,98 @@ +// 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.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.SystemWebAdapters.SessionState.RemoteSession; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; + +internal readonly struct BinarySessionStateSerializer(ISessionKeySerializer serializer) +{ + public void Write(ISessionState state, BinaryWriter writer) + { + writer.Write(BinarySessionSerializer.Version1); + writer.Write(state.SessionID); + + writer.Write(state.IsNewSession); + writer.Write(state.IsAbandoned); + writer.Write(state.IsReadOnly); + + writer.Write7BitEncodedInt(state.Timeout); + writer.Write7BitEncodedInt(state.Count); + + List? unknownKeys = null; + + foreach (var item in state.Keys) + { + writer.Write(item); + + if (serializer.TrySerialize(item, state[item], out var result)) + { + writer.Write7BitEncodedInt(result.Length); + writer.Write(result); + } + else + { + (unknownKeys ??= new()).Add(item); + writer.Write7BitEncodedInt(0); + } + } + + if (unknownKeys is null) + { + writer.Write7BitEncodedInt(0); + } + else + { + writer.Write7BitEncodedInt(unknownKeys.Count); + + foreach (var key in unknownKeys) + { + writer.Write(key); + } + } + } + + public SessionStateCollection Read(BinaryReader reader) + { + var state = new SessionStateCollection(serializer); + + state.SessionID = reader.ReadString(); + state.IsNewSession = reader.ReadBoolean(); + state.IsAbandoned = reader.ReadBoolean(); + state.IsReadOnly = reader.ReadBoolean(); + state.Timeout = reader.Read7BitEncodedInt(); + + var count = reader.Read7BitEncodedInt(); + + for (var index = count; index > 0; index--) + { + var key = reader.ReadString(); + var length = reader.Read7BitEncodedInt(); + var bytes = reader.ReadBytes(length); + + state.SetData(key, bytes); + } + + var unknown = reader.Read7BitEncodedInt(); + + if (unknown > 0) + { + for (var index = unknown; index > 0; index--) + { + state.SetUnknownKey(reader.ReadString()); + } + } + + return state; + } +} diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/Serialization/BinaryWriterReaderExtensions.cs b/src/Services/SessionState/BinaryWriterReaderExtensions.cs similarity index 96% rename from src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/Serialization/BinaryWriterReaderExtensions.cs rename to src/Services/SessionState/BinaryWriterReaderExtensions.cs index e684db650..1ee67390b 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/Serialization/BinaryWriterReaderExtensions.cs +++ b/src/Services/SessionState/BinaryWriterReaderExtensions.cs @@ -1,6 +1,12 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#if NETFRAMEWORK + +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; using System.Runtime.CompilerServices; namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; @@ -77,4 +83,4 @@ public static int Read7BitEncodedInt(this BinaryReader reader) return (int)result; } } - +#endif diff --git a/src/Services/SessionState/CompositeSessionKeySerializer.cs b/src/Services/SessionState/CompositeSessionKeySerializer.cs index 1233632f6..3eadf19c4 100644 --- a/src/Services/SessionState/CompositeSessionKeySerializer.cs +++ b/src/Services/SessionState/CompositeSessionKeySerializer.cs @@ -4,16 +4,28 @@ using System; using System.Collections.Generic; using System.Linq; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; -internal sealed class CompositeSessionKeySerializer : ICompositeSessionKeySerializer +internal sealed partial class CompositeSessionKeySerializer : ICompositeSessionKeySerializer { private readonly ISessionKeySerializer[] _serializers; + private readonly IOptions _options; + private readonly ILogger _logger; - public CompositeSessionKeySerializer(IEnumerable serializers) + [LoggerMessage(0, LogLevel.Warning, "Could not serialize session value for key '{Key}'")] + private partial void LogUnknownSessionKeySerialize(string key); + + [LoggerMessage(1, LogLevel.Warning, "Could not deserialize session value for key '{Key}'")] + private partial void LogUnknownSessionKeyDeserialize(string key); + + public CompositeSessionKeySerializer(IEnumerable serializers, IOptions options, ILogger logger) { _serializers = serializers.ToArray(); + _options = options; + _logger = logger; } public bool TrySerialize(string key, object? value, out byte[] bytes) @@ -26,6 +38,13 @@ public bool TrySerialize(string key, object? value, out byte[] bytes) } } + LogUnknownSessionKeySerialize(key); + + if (_options.Value.ThrowOnUnknownSessionKey) + { + throw new UnknownSessionKeyException(key); + } + bytes = Array.Empty(); return false; } @@ -40,6 +59,13 @@ public bool TryDeserialize(string key, byte[] bytes, out object? obj) } } + LogUnknownSessionKeyDeserialize(key); + + if (_options.Value.ThrowOnUnknownSessionKey) + { + throw new UnknownSessionKeyException(key); + } + obj = null; return false; } diff --git a/src/Services/SessionState/ISessionSerializer.cs b/src/Services/SessionState/ISessionSerializer.cs index 534d87054..aa799f448 100644 --- a/src/Services/SessionState/ISessionSerializer.cs +++ b/src/Services/SessionState/ISessionSerializer.cs @@ -1,15 +1,32 @@ // 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.Threading; using System.Threading.Tasks; +using System.Collections.Generic; +using System.Linq; namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; internal interface ISessionSerializer { + /// + /// Deserializes a session state. + /// + /// The serialized session stream. + /// A cancellation token + /// If the stream defines a serialized session changeset, it will also implement . Task DeserializeAsync(Stream stream, CancellationToken token); - Task SerializeAsync(ISessionState state, Stream stream, CancellationToken token); + /// + /// Serializes the session state. If the implements it will serialize it + /// in a mode that tracks the changes that have occurred. + /// + /// + /// + /// + /// + Task SerializeAsync(ISessionState state, SessionSerializerContext context, Stream stream, CancellationToken token); } diff --git a/src/Services/SessionState/ISessionStateChangeset.cs b/src/Services/SessionState/ISessionStateChangeset.cs new file mode 100644 index 000000000..4d00579b3 --- /dev/null +++ b/src/Services/SessionState/ISessionStateChangeset.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; + +namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; + +internal interface ISessionStateChangeset : ISessionState +{ + IEnumerable Changes { get; } +} diff --git a/src/Services/SessionState/SessionConstants.cs b/src/Services/SessionState/SessionConstants.cs index 219518a9a..f57283fc0 100644 --- a/src/Services/SessionState/SessionConstants.cs +++ b/src/Services/SessionState/SessionConstants.cs @@ -6,6 +6,7 @@ namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState; internal static class SessionConstants { public const string ReadOnlyHeaderName = "X-SystemWebAdapter-RemoteAppSession-ReadOnly"; + public const string SupportedVersion = "X-SystemWebAdapter-RemoteAppSession-Version"; public const string SessionEndpointPath = "/systemweb-adapters/session"; diff --git a/src/Services/SessionState/SessionItemChangeState.cs b/src/Services/SessionState/SessionItemChangeState.cs new file mode 100644 index 000000000..caae0efec --- /dev/null +++ b/src/Services/SessionState/SessionItemChangeState.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; + +internal enum SessionItemChangeState +{ + Unknown = 0, + NoChange = 1, + Removed = 2, + Changed = 3, + New = 4, +} diff --git a/src/Services/SessionState/SessionSerializerContext.cs b/src/Services/SessionState/SessionSerializerContext.cs new file mode 100644 index 000000000..715beb480 --- /dev/null +++ b/src/Services/SessionState/SessionSerializerContext.cs @@ -0,0 +1,40 @@ +// 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.Threading; +using System.Threading.Tasks; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; + +internal sealed class SessionSerializerContext(byte supportedVersion) +{ + public static SessionSerializerContext V1 { get; } = new(BinarySessionSerializer.Version1); + + public static SessionSerializerContext V2 { get; } = new(BinarySessionSerializer.Version2); + + public static SessionSerializerContext Latest => V2; + + public static SessionSerializerContext Default => V1; + + public byte SupportedVersion => supportedVersion; + + public static SessionSerializerContext Parse(IEnumerable all) => all.Select(Parse).Max() ?? V1; + + public static SessionSerializerContext Parse(string? supportedVersionString) => supportedVersionString switch + { + "1" => V1, + "2" => V2, + _ => V1, + }; + + public static SessionSerializerContext Get(byte v) => v switch + { + 1 => V1, + 2 => V2, + _ => throw new ArgumentOutOfRangeException(nameof(v)) + }; +} diff --git a/src/Services/SessionState/SessionStateChangeItem.cs b/src/Services/SessionState/SessionStateChangeItem.cs new file mode 100644 index 000000000..056a5119f --- /dev/null +++ b/src/Services/SessionState/SessionStateChangeItem.cs @@ -0,0 +1,43 @@ +// 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.Diagnostics; + +namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; + +[DebuggerDisplay("{State}: {Key,nq}")] +internal readonly struct SessionStateChangeItem(SessionItemChangeState state, string key) : IEquatable +{ + public SessionItemChangeState State => state; + + public string Key => key; + + public override bool Equals(object? obj) => obj is SessionStateChangeItem item && Equals(item); + + public override int GetHashCode() +#if NET + { + var hash = new HashCode(); + hash.Add(State); + hash.Add(Key, StringComparer.OrdinalIgnoreCase); + return hash.ToHashCode(); + } +#else + => State.GetHashCode() ^ StringComparer.OrdinalIgnoreCase.GetHashCode(Key); +#endif + + public bool Equals(SessionStateChangeItem other) => + State == other.State + && string.Equals(Key, other.Key, StringComparison.Ordinal); + + public static bool operator ==(SessionStateChangeItem left, SessionStateChangeItem right) + { + return left.Equals(right); + } + + public static bool operator !=(SessionStateChangeItem left, SessionStateChangeItem right) + { + return !(left == right); + } +} diff --git a/src/Services/SessionState/SessionStateCollection.cs b/src/Services/SessionState/SessionStateCollection.cs new file mode 100644 index 000000000..3c089706c --- /dev/null +++ b/src/Services/SessionState/SessionStateCollection.cs @@ -0,0 +1,218 @@ +// 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.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; + +internal class SessionStateCollection : ISessionState +{ + private readonly Dictionary _items; + + public SessionStateCollection(ISessionKeySerializer serializer) + { + Serializer = serializer; + _items = []; + } + + protected SessionStateCollection(SessionStateCollection other) + { + _items = other._items; + + Serializer = other.Serializer; + UnknownKeys = other.UnknownKeys; + + SessionID = other.SessionID; + IsReadOnly = other.IsReadOnly; + IsNewSession = other.IsNewSession; + IsAbandoned = other.IsAbandoned; + Timeout = other.Timeout; + } + + public static SessionStateCollection CreateTracking(ISessionKeySerializer serializer) + => new SessionStateChangeset(serializer); + + public SessionStateCollection WithTracking() => new SessionStateChangeset(this); + + public ISessionKeySerializer Serializer { get; } + + public void SetUnknownKey(string key) + { + (UnknownKeys ??= new()).Add(key); + _items.Remove(key); + } + + public void MarkUnchanged(string key) => _items[key] = ItemHolder.Unchanged(); + + public void MarkRemoved(string key) => _items[key] = ItemHolder.Removed(); + + public void SetData(string key, byte[] data) => _items[key] = ItemHolder.FromData(data); + + public object? this[string key] + { + get => _items.TryGetValue(key, out var result) ? result.GetValue(key, Serializer) : null; + set + { + if (_items.TryGetValue(key, out var existing)) + { + existing.SetValue(value); + } + else if (value is { }) + { + _items[key] = ItemHolder.NewValue(value); + } + } + } + + public IEnumerable Changes + { + get + { + foreach (var item in _items) + { + yield return new(item.Value.State, item.Key); + } + } + } + + internal List? UnknownKeys { get; private set; } + + public string SessionID { get; set; } = null!; + + public bool IsReadOnly { get; set; } + + public int Timeout { get; set; } + + public bool IsNewSession { get; set; } + + public int Count => _items?.Count ?? 0; + + public bool IsAbandoned { get; set; } + + bool ISessionState.IsSynchronized => false; + + object ISessionState.SyncRoot => this; + + public IEnumerable Keys => _items?.Keys ?? Enumerable.Empty(); + + public void Clear() + { + List? newKeys = null; + + foreach (var item in _items) + { + if (item.Value.IsNew) + { + (newKeys ??= []).Add(item.Key); + } + else + { + item.Value.SetValue(null); + } + } + + if (newKeys is { }) + { + foreach (var key in newKeys) + { + _items.Remove(key); + } + } + } + + public void Remove(string key) + { + if (_items.TryGetValue(key, out var existing)) + { + if (existing.IsNew) + { + _items.Remove(key); + } + else + { + existing.SetValue(null); + } + } + } + + Task ISessionState.CommitAsync(CancellationToken token) => Task.CompletedTask; + + void IDisposable.Dispose() + { + } + + private sealed class ItemHolder + { + private byte[]? _data; + private object? _value; + + private ItemHolder(bool isNew = false) + { + IsNew = isNew; + } + + public bool IsNew { get; } + + public SessionItemChangeState State => (IsNew, _data, _value) switch + { + (true, _, _) => SessionItemChangeState.New, + + // If both are null, the value has been set to null implying it no longer exists + (_, null, null) => SessionItemChangeState.Removed, + + // If the value is set, it means it has been accessed and then potentially changed + (_, _, { }) => SessionItemChangeState.Changed, + + // If the data is still set, then the value has not been accessed + (_, { }, _) => SessionItemChangeState.NoChange, + }; + + public object? GetValue(string key, ISessionKeySerializer serializer) + { + if (_data is { } data && serializer.TryDeserialize(key, data, out var obj)) + { + _value = obj; + _data = null; + } + + return _value; + } + + internal void SetValue(object? value) + { + _value = value; + _data = null; + } + + public static ItemHolder Removed() => new(); + + public static ItemHolder FromData(byte[] bytes) => new() { _data = bytes }; + + public static ItemHolder FromValue(object? value) => new() { _value = value }; + + public static ItemHolder NewValue(object value) => new(isNew: true) { _value = value }; + + public static ItemHolder Unchanged() => new() { _data = [] }; + } + + private sealed class SessionStateChangeset : SessionStateCollection, ISessionStateChangeset + { + public SessionStateChangeset(ISessionKeySerializer serializer) + : base(serializer) + { + } + + public SessionStateChangeset(SessionStateCollection other) + : base(other) + { + } + } +} diff --git a/test/Microsoft.AspNetCore.SystemWebAdapters.CoreServices.Tests/SessionState/Serialization/BinarySessionSerializerTests.cs b/test/Microsoft.AspNetCore.SystemWebAdapters.CoreServices.Tests/SessionState/Serialization/BinarySessionSerializerTests.cs index a370522a4..3ecf7bc4a 100644 --- a/test/Microsoft.AspNetCore.SystemWebAdapters.CoreServices.Tests/SessionState/Serialization/BinarySessionSerializerTests.cs +++ b/test/Microsoft.AspNetCore.SystemWebAdapters.CoreServices.Tests/SessionState/Serialization/BinarySessionSerializerTests.cs @@ -11,10 +11,13 @@ namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization.Tests; +[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1062:Validate arguments of public methods", Justification = "Test arguments")] public class BinarySessionSerializerTests { - [Fact] - public async Task SerializeEmpty() + [InlineData(new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 0, 0 })] + [InlineData(new byte[] { 2, 2, 105, 100, 0, 0, 0, 0, 0xFF })] + [Theory] + public async Task SerializeEmpty(byte[] data) { // Arrange var serializer = CreateSerializer(); @@ -24,17 +27,18 @@ public async Task SerializeEmpty() state.Setup(s => s.SessionID).Returns("id"); // Act - await serializer.SerializeAsync(state.Object, ms, default); + await serializer.SerializeAsync(state.Object, SessionSerializerContext.Get(data[0]), ms, default); // Assert - Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 0, 0 }); + Assert.Equal(ms.ToArray(), data); } - [Fact] - public async Task DeserializeEmpty() + [InlineData(new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 0, 0 })] + [InlineData(new byte[] { 2, 2, 105, 100, 0, 0, 0, 0, 0xFF })] + [Theory] + public async Task DeserializeEmpty(byte[] data) { // Arrange - var data = new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 0, 0 }; using var ms = new MemoryStream(data); var serializer = CreateSerializer(); @@ -52,8 +56,10 @@ public async Task DeserializeEmpty() Assert.Empty(result.Keys); } - [Fact] - public async Task SerializeIsNewSession() + [InlineData(new byte[] { 1, 2, 105, 100, 1, 0, 0, 0, 0, 0 })] + [InlineData(new byte[] { 2, 2, 105, 100, 1, 0, 0, 0, 0xFF })] + [Theory] + public async Task SerializeIsNewSession(byte[] data) { // Arrange var serializer = CreateSerializer(); @@ -64,17 +70,18 @@ public async Task SerializeIsNewSession() state.Setup(s => s.IsNewSession).Returns(true); // Act - await serializer.SerializeAsync(state.Object, ms, default); + await serializer.SerializeAsync(state.Object, SessionSerializerContext.Get(data[0]), ms, default); // Assert - Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 1, 0, 0, 0, 0, 0 }); + Assert.Equal(ms.ToArray(), data); } - [Fact] - public async Task DeserializeIsNewSession() + [InlineData(new byte[] { 1, 2, 105, 100, 1, 0, 0, 0, 0, 0 })] + [InlineData(new byte[] { 2, 2, 105, 100, 1, 0, 0, 0, 0XFF })] + [Theory] + public async Task DeserializeIsNewSession(byte[] data) { // Arrange - var data = new byte[] { 1, 2, 105, 100, 1, 0, 0, 0, 0, 0 }; using var ms = new MemoryStream(data); var serializer = CreateSerializer(); @@ -92,8 +99,10 @@ public async Task DeserializeIsNewSession() Assert.Empty(result.Keys); } - [Fact] - public async Task SerializeIsAbandoned() + [InlineData(new byte[] { 1, 2, 105, 100, 0, 1, 0, 0, 0, 0 })] + [InlineData(new byte[] { 2, 2, 105, 100, 0, 1, 0, 0, 0xFF })] + [Theory] + public async Task SerializeIsAbandoned(byte[] data) { // Arrange var serializer = CreateSerializer(); @@ -104,17 +113,18 @@ public async Task SerializeIsAbandoned() state.Setup(s => s.IsAbandoned).Returns(true); // Act - await serializer.SerializeAsync(state.Object, ms, default); + await serializer.SerializeAsync(state.Object, SessionSerializerContext.Get(data[0]), ms, default); // Assert - Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 0, 1, 0, 0, 0, 0 }); + Assert.Equal(ms.ToArray(), data); } - [Fact] - public async Task DeserializeIsAbandoned() + [InlineData(new byte[] { 1, 2, 105, 100, 0, 1, 0, 0, 0, 0 })] + [InlineData(new byte[] { 2, 2, 105, 100, 0, 1, 0, 0, 0xFF })] + [Theory] + public async Task DeserializeIsAbandoned(byte[] data) { // Arrange - var data = new byte[] { 1, 2, 105, 100, 0, 1, 0, 0, 0, 0 }; using var ms = new MemoryStream(data); var serializer = CreateSerializer(); @@ -132,8 +142,10 @@ public async Task DeserializeIsAbandoned() Assert.Empty(result.Keys); } - [Fact] - public async Task SerializeIsReadOnly() + [InlineData(new byte[] { 1, 2, 105, 100, 0, 0, 1, 0, 0, 0 })] + [InlineData(new byte[] { 2, 2, 105, 100, 0, 0, 1, 0, 0xFF })] + [Theory] + public async Task SerializeIsReadOnly(byte[] data) { // Arrange var serializer = CreateSerializer(); @@ -144,17 +156,18 @@ public async Task SerializeIsReadOnly() state.Setup(s => s.IsReadOnly).Returns(true); // Act - await serializer.SerializeAsync(state.Object, ms, default); + await serializer.SerializeAsync(state.Object, SessionSerializerContext.Get(data[0]), ms, default); // Assert - Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 0, 0, 1, 0, 0, 0 }); + Assert.Equal(ms.ToArray(), data); } - [Fact] - public async Task DeserializeIsReadOnly() + [InlineData(new byte[] { 1, 2, 105, 100, 0, 0, 1, 0, 0, 0 })] + [InlineData(new byte[] { 2, 2, 105, 100, 0, 0, 1, 0, 0xFF })] + [Theory] + public async Task DeserializeIsReadOnly(byte[] data) { // Arrange - var data = new byte[] { 1, 2, 105, 100, 0, 0, 1, 0, 0, 0 }; using var ms = new MemoryStream(data); var serializer = CreateSerializer(); @@ -172,11 +185,12 @@ public async Task DeserializeIsReadOnly() Assert.Empty(result.Keys); } - [Fact] - public async Task DeserializeIsReadOnlyEmptyNull() + [InlineData(new byte[] { 1, 2, 105, 100, 0, 0, 1, 0, 0, 0 })] + [InlineData(new byte[] { 2, 2, 105, 100, 0, 0, 1, 0, 0xFF })] + [Theory] + public async Task DeserializeIsReadOnlyEmptyNull(byte[] data) { // Arrange - var data = new byte[] { 1, 2, 105, 100, 0, 0, 1, 0, 0, 0 }; using var ms = new MemoryStream(data); var serializer = CreateSerializer(); @@ -194,8 +208,10 @@ public async Task DeserializeIsReadOnlyEmptyNull() Assert.Empty(result.Keys); } - [Fact] - public async Task SerializeTimeout() + [InlineData(new byte[] { 1, 2, 105, 100, 0, 0, 0, 20, 0, 0 })] + [InlineData(new byte[] { 2, 2, 105, 100, 0, 0, 0, 20, 0xFF })] + [Theory] + public async Task SerializeTimeout(byte[] data) { // Arrange var serializer = CreateSerializer(); @@ -206,17 +222,18 @@ public async Task SerializeTimeout() state.Setup(s => s.Timeout).Returns(20); // Act - await serializer.SerializeAsync(state.Object, ms, default); + await serializer.SerializeAsync(state.Object, SessionSerializerContext.Get(data[0]), ms, default); // Assert - Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 0, 0, 0, 20, 0, 0 }); + Assert.Equal(ms.ToArray(), data); } - [Fact] - public async Task DeserializeTimeout() + [InlineData(new byte[] { 1, 2, 105, 100, 0, 0, 0, 20, 0, 0 })] + [InlineData(new byte[] { 2, 2, 105, 100, 0, 0, 0, 20, 0xFF })] + [Theory] + public async Task DeserializeTimeout(byte[] data) { // Arrange - var data = new byte[] { 1, 2, 105, 100, 0, 0, 0, 20, 0, 0 }; using var ms = new MemoryStream(data); var serializer = CreateSerializer(); @@ -234,8 +251,10 @@ public async Task DeserializeTimeout() Assert.Empty(result.Keys); } - [Fact] - public async Task Serialize1Key() + [InlineData(new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 42, 0 })] + [InlineData(new byte[] { 2, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 42, 0xFF })] + [Theory] + public async Task Serialize1Key(byte[] data) { // Arrange var obj = new object(); @@ -253,14 +272,16 @@ public async Task Serialize1Key() using var ms = new MemoryStream(); // Act - await serializer.SerializeAsync(state.Object, ms, default); + await serializer.SerializeAsync(state.Object, SessionSerializerContext.Get(data[0]), ms, default); // Assert - Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 42, 0 }); + Assert.Equal(ms.ToArray(), data); } - [Fact] - public async Task Serialize1KeyNull() + [InlineData(new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 0, 0 })] + [InlineData(new byte[] { 2, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 0, 0xFF })] + [Theory] + public async Task Serialize1KeyNull(byte[] data) { // Arrange var obj = default(object); @@ -278,17 +299,18 @@ public async Task Serialize1KeyNull() using var ms = new MemoryStream(); // Act - await serializer.SerializeAsync(state.Object, ms, default); + await serializer.SerializeAsync(state.Object, SessionSerializerContext.Get(data[0]), ms, default); // Assert - Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 0, 0 }); + Assert.Equal(ms.ToArray(), data); } - [Fact] - public async Task Deserialize1KeyNull() + [InlineData(new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 0, 0 })] + [InlineData(new byte[] { 2, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 0, 0xFF })] + [Theory] + public async Task Deserialize1KeyNull(byte[] data) { // Arrange - var data = new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 0, 0 }; var obj = new object(); var value = new byte[] { 0 }; @@ -311,15 +333,16 @@ public async Task Deserialize1KeyNull() Assert.Collection(result.Keys, k => Assert.Equal("key1", k)); } - [Fact] - public async Task Deserialize1KeyV1() + [InlineData(new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 0, 0 })] + [InlineData(new byte[] { 2, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 0, 0xFF })] + [Theory] + public async Task Deserialize1KeyV1(byte[] data) { // Arrange var obj = new object(); var keySerializer = new Mock(); keySerializer.Setup(k => k.TryDeserialize("key1", Array.Empty(), out obj)).Returns(true); - var data = new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 0, 0 }; using var ms = new MemoryStream(data); var serializer = CreateSerializer(keySerializer.Object); @@ -338,8 +361,10 @@ public async Task Deserialize1KeyV1() Assert.Equal(obj, result["key1"]); } - [Fact] - public async Task Serialize1KeyNullable() + [InlineData(new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 0, 0 })] + [InlineData(new byte[] { 2, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 0, 0xFF })] + [Theory] + public async Task Serialize1KeyNullable(byte[] data) { // Arrange var obj = (int?)5; @@ -357,14 +382,16 @@ public async Task Serialize1KeyNullable() using var ms = new MemoryStream(); // Act - await serializer.SerializeAsync(state.Object, ms, default); + await serializer.SerializeAsync(state.Object, SessionSerializerContext.Get(data[0]), ms, default); // Assert - Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 0, 0 }); + Assert.Equal(ms.ToArray(), data); } - [Fact] - public async Task Deserialize1Key() + [InlineData(new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 42, 0 })] + [InlineData(new byte[] { 2, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 42, 0xFF })] + [Theory] + public async Task Deserialize1Key(byte[] data) { // Arrange var obj = new object(); @@ -372,7 +399,6 @@ public async Task Deserialize1Key() var keySerializer = new Mock(); keySerializer.Setup(k => k.TryDeserialize("key1", bytes, out obj)).Returns(true); - var data = new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 42, 0 }; using var ms = new MemoryStream(data); var serializer = CreateSerializer(keySerializer.Object); diff --git a/test/Microsoft.AspNetCore.SystemWebAdapters.CoreServices.Tests/SessionState/Serialization/SessionStateCollectionTests.cs b/test/Microsoft.AspNetCore.SystemWebAdapters.CoreServices.Tests/SessionState/Serialization/SessionStateCollectionTests.cs new file mode 100644 index 000000000..f0db9c40e --- /dev/null +++ b/test/Microsoft.AspNetCore.SystemWebAdapters.CoreServices.Tests/SessionState/Serialization/SessionStateCollectionTests.cs @@ -0,0 +1,288 @@ +// 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; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Client; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization.Tests; + +public class SessionStateCollectionTests +{ + [Fact] + public void EmptyState() + { + // Arrange + var serializer = new Mock(); + using var state = new SessionStateCollection(serializer.Object); + + // Act/Assert + Assert.Equal(0, state.Count); + } + + [Fact] + public void EnableTracking() + { + // Arrange + var serializer = new Mock(); + using var state = new SessionStateCollection(serializer.Object); + + state.SessionID = Guid.NewGuid().ToString(); + state.IsNewSession = true; + state.IsAbandoned = true; + state.Timeout = 5; + + const string SessionKey = "item1"; + var item1 = new object(); + state[SessionKey] = item1; + + // Act + var tracking = state.WithTracking(); + + // Assert + Assert.IsAssignableFrom(tracking); + Assert.Equal(1, state.Count); + Assert.Equal(state.SessionID, tracking.SessionID); + Assert.Equal(state.IsNewSession, tracking.IsNewSession); + Assert.Equal(state.IsAbandoned, tracking.IsAbandoned); + Assert.Equal(state.Timeout, tracking.Timeout); + Assert.Same(state[SessionKey], tracking[SessionKey]); + } + + [Fact] + public void AddNewValue() + { + // Arrange + const string Key = "key"; + object value = new(); + var serializer = new Mock(); + using var state = new SessionStateCollection(serializer.Object); + + // Act + state[Key] = value; + + // Assert + Assert.Same(state[Key], value); + Assert.Collection(state.Changes, + c => + { + Assert.Equal(Key, c.Key); + Assert.Equal(SessionItemChangeState.New, c.State); + }); + } + + [Fact] + public void SetItem() + { + // Arrange + const string Key = "key"; + byte[] value = []; + var serializer = new Mock(); + using var state = new SessionStateCollection(serializer.Object); + + // Act + state.SetData(Key, value); + + // Assert + Assert.Collection(state.Changes, + c => + { + Assert.Equal(Key, c.Key); + Assert.Equal(SessionItemChangeState.NoChange, c.State); + }); + } + + [Fact] + public void SetItemAndAccess() + { + // Arrange + const string Key = "key"; + byte[] data = []; + object? value = new(); + var serializer = new Mock(); + serializer.Setup(s => s.TryDeserialize(Key, data, out value)).Returns(true); + using var state = new SessionStateCollection(serializer.Object); + + // Act + state.SetData(Key, data); + var result = state[Key]; + + // Assert + Assert.Same(value, result); + Assert.Collection(state.Changes, + c => + { + Assert.Equal(Key, c.Key); + Assert.Equal(SessionItemChangeState.Changed, c.State); + }); + } + + [Fact] + public void SetItemAndAccessButCannotDeserialize() + { + // Arrange + const string Key = "key"; + byte[] data = []; + object? value = new(); + var serializer = new Mock(); + serializer.Setup(s => s.TryDeserialize(Key, data, out value)).Returns(false); + using var state = new SessionStateCollection(serializer.Object); + + // Act + state.SetData(Key, data); + var result = state[Key]; + + // Assert + Assert.Null(result); + Assert.Collection(state.Changes, + c => + { + Assert.Equal(Key, c.Key); + Assert.Equal(SessionItemChangeState.NoChange, c.State); + }); + } + + [Fact] + public void AddItemAndRemove() + { + // Arrange + const string Key = "key"; + var serializer = new Mock(); + using var state = new SessionStateCollection(serializer.Object); + state[Key] = new(); + + // Act + state.Remove(Key); + + // Assert + Assert.Empty(state.Changes); + } + + [Fact] + public void SetItemAndRemove() + { + // Arrange + const string Key = "key"; + var serializer = new Mock(); + using var state = new SessionStateCollection(serializer.Object); + state.SetData(Key, []); + + // Act + state.Remove(Key); + + // Assert + Assert.Collection(state.Changes, + c => + { + Assert.Equal(Key, c.Key); + Assert.Equal(SessionItemChangeState.Removed, c.State); + }); + } + + [Fact] + public void MarkItemRemoved() + { + // Arrange + const string Key = "key"; + var serializer = new Mock(); + using var state = new SessionStateCollection(serializer.Object); + + // Act + state.MarkRemoved(Key); + + // Assert + Assert.Collection(state.Changes, + c => + { + Assert.Equal(Key, c.Key); + Assert.Equal(SessionItemChangeState.Removed, c.State); + }); + } + + [Fact] + public void MarkItemUnchanged() + { + // Arrange + const string Key = "key"; + var serializer = new Mock(); + using var state = new SessionStateCollection(serializer.Object); + + // Act + state.MarkUnchanged(Key); + + // Assert + Assert.Collection(state.Changes, + c => + { + Assert.Equal(Key, c.Key); + Assert.Equal(SessionItemChangeState.NoChange, c.State); + }); + } + + [Fact] + public void ClearAddedItems() + { + // Arrange + const string Key = "key"; + var serializer = new Mock(); + using var state = new SessionStateCollection(serializer.Object); + state[Key] = new(); + + // Act + state.Clear(); + + // Assert + Assert.Empty(state.Changes); + } + + [Fact] + public void ClearSetItem() + { + // Arrange + const string Key = "key"; + var serializer = new Mock(); + using var state = new SessionStateCollection(serializer.Object); + state.SetData(Key, []); + + // Act + state.Clear(); + + // Assert + Assert.Collection(state.Changes, + c => + { + Assert.Equal(Key, c.Key); + Assert.Equal(SessionItemChangeState.Removed, c.State); + }); + } + + [Fact] + public void SetUnknownKey() + { + // Arrange + const string Key = "key"; + var serializer = new Mock(); + using var state = new SessionStateCollection(serializer.Object); + + // Act + state.SetData(Key, []); + state.SetUnknownKey(Key); + + // Assert + Assert.Equal(0, state.Count); + Assert.Empty(state.Keys); + Assert.Empty(state.Changes); + Assert.Equal([Key], state.UnknownKeys); + } +} + diff --git a/test/Microsoft.AspNetCore.SystemWebAdapters.CoreServices.Tests/SessionState/Wrapped/CompositeSessionKeySerializerTests.cs b/test/Microsoft.AspNetCore.SystemWebAdapters.CoreServices.Tests/SessionState/Wrapped/CompositeSessionKeySerializerTests.cs index 853a3e882..1b64f1574 100644 --- a/test/Microsoft.AspNetCore.SystemWebAdapters.CoreServices.Tests/SessionState/Wrapped/CompositeSessionKeySerializerTests.cs +++ b/test/Microsoft.AspNetCore.SystemWebAdapters.CoreServices.Tests/SessionState/Wrapped/CompositeSessionKeySerializerTests.cs @@ -1,8 +1,9 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Moq; using Xunit; @@ -23,10 +24,10 @@ public void MultipleDeserializers() serializer1.Setup(s => s.TryDeserialize("key1", bytes1, out obj1)).Returns(true); var serializer2 = new Mock(); serializer2.Setup(s => s.TryDeserialize("key2", bytes2, out obj2)).Returns(true); - var loggerFactory = new Mock(); + var logger = new Mock>(); // Act - var combined = new CompositeSessionKeySerializer(new[] { serializer1.Object, serializer2.Object }); + var combined = new CompositeSessionKeySerializer(new[] { serializer1.Object, serializer2.Object }, Options.Create(new SessionSerializerOptions()), logger.Object); // Assert Assert.True(combined.TryDeserialize("key1", bytes1, out var result1)); @@ -48,10 +49,10 @@ public void MultipleSerializers() serializer1.Setup(s => s.TrySerialize("key1", obj1, out bytes1)).Returns(true); var serializer2 = new Mock(); serializer2.Setup(s => s.TrySerialize("key2", obj2, out bytes2)).Returns(true); - var loggerFactory = new Mock(); + var logger = new Mock>(); // Act - var combined = new CompositeSessionKeySerializer(new[] { serializer1.Object, serializer2.Object }); + var combined = new CompositeSessionKeySerializer(new[] { serializer1.Object, serializer2.Object }, Options.Create(new SessionSerializerOptions()), logger.Object); ; // Assert Assert.True(combined.TrySerialize("key1", obj1, out var result1)); diff --git a/test/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices.Tests/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices.Tests.csproj b/test/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices.Tests/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices.Tests.csproj index 306a2cd28..ef44f97f5 100644 --- a/test/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices.Tests/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices.Tests.csproj +++ b/test/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices.Tests/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices.Tests.csproj @@ -6,6 +6,7 @@ + diff --git a/test/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices.Tests/RemoteSession/GetWriteableSessionHandlerTests.cs b/test/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices.Tests/RemoteSession/GetWriteableSessionHandlerTests.cs index e36111727..007472de6 100644 --- a/test/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices.Tests/RemoteSession/GetWriteableSessionHandlerTests.cs +++ b/test/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices.Tests/RemoteSession/GetWriteableSessionHandlerTests.cs @@ -63,13 +63,15 @@ public async Task RequestCompleted() context.Setup(c => c.Response).Returns(response.Object); context.Setup(c => c.Session).Returns(session.Object); - serializer.Setup(s => s.SerializeAsync(It.Is(t => t.State == session.Object), stream, It.IsAny())).Callback(() => + var serializationContext = SessionSerializerContext.Default; + + serializer.Setup(s => s.SerializeAsync(It.Is(t => t.State == session.Object), serializationContext, stream, It.IsAny())).Callback(() => { stream.WriteByte(expectedByte); }); // Act - var task = handler.ProcessRequestAsync(context.Object, default); + var task = handler.ProcessRequestAsync(context.Object, serializationContext, default); Assert.False(task.IsCompleted); lockDisposable.Verify(d => d.Dispose(), Times.Never); @@ -122,13 +124,15 @@ public async Task DisconnectedRequest() context.Setup(c => c.Response).Returns(response.Object); context.Setup(c => c.Session).Returns(session.Object); - serializer.Setup(s => s.SerializeAsync(It.Is(t => t.State == session.Object), stream, It.IsAny())).Callback(() => + var serializationContext = SessionSerializerContext.Default; + + serializer.Setup(s => s.SerializeAsync(It.Is(t => t.State == session.Object), serializationContext, stream, It.IsAny())).Callback(() => { stream.WriteByte(expectedByte); }); // Act - var task = handler.ProcessRequestAsync(context.Object, cts.Token); + var task = handler.ProcessRequestAsync(context.Object, serializationContext, cts.Token); Assert.False(task.IsCompleted); lockDisposable.Verify(d => d.Dispose(), Times.Never); diff --git a/test/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices.Tests/RemoteSession/ReadOnlySessionHandlerTests.cs b/test/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices.Tests/RemoteSession/ReadOnlySessionHandlerTests.cs index 4833b8674..e4cfb2f20 100644 --- a/test/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices.Tests/RemoteSession/ReadOnlySessionHandlerTests.cs +++ b/test/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices.Tests/RemoteSession/ReadOnlySessionHandlerTests.cs @@ -44,13 +44,15 @@ public async Task Process() context.Setup(c => c.Response).Returns(response.Object); context.Setup(c => c.Session).Returns(session.Object); + var serializationContext = SessionSerializerContext.Default; + // Act - await handler.ProcessRequestAsync(context.Object); + await handler.ProcessRequestAsync(context.Object, serializationContext, default); // Assert Assert.Equal(200, response.Object.StatusCode); Assert.Equal("application/json; charset=utf-8", response.Object.ContentType); - serializer.Verify(s => s.SerializeAsync(It.Is(t => t.State == session.Object), output.Object, default), Times.Once); + serializer.Verify(s => s.SerializeAsync(It.Is(t => t.State == session.Object), serializationContext, output.Object, default), Times.Once); } } diff --git a/test/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices.Tests/RemoteSession/RemoteSessionModuleTests.cs b/test/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices.Tests/RemoteSession/RemoteSessionModuleTests.cs index e020e5463..098f21820 100644 --- a/test/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices.Tests/RemoteSession/RemoteSessionModuleTests.cs +++ b/test/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices.Tests/RemoteSession/RemoteSessionModuleTests.cs @@ -6,6 +6,7 @@ using System.Web; using AutoFixture; using Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Moq; using Xunit; @@ -47,8 +48,9 @@ public void VerifyCorrectHandler(string method, string? readOnlyHeaderValue, int var sessions = new Mock(); var serializer = new Mock(); + var factory = new Mock(); - var module = new RemoteSessionModule(sessionOptions, remoteAppOptions, sessions.Object, serializer.Object); + var module = new RemoteSessionModule(sessionOptions, remoteAppOptions, factory.Object, sessions.Object, serializer.Object); var headers = new NameValueCollection {