From bbd90dd742d2f499b90037a8c6f8cf2c781bbe2f Mon Sep 17 00:00:00 2001 From: Taylor Southwick Date: Thu, 5 Dec 2024 19:12:14 -0800 Subject: [PATCH 01/10] Support serializing session changesets Previously, the serialization of the session state to go between the remote server and the client would deserialize/serialize every key even if it hadn't changed. As part of this change: - The session state tracks which items are new or have been accessed (and thus potentially changed) - Only sends a diff list rather than all the items - Only deserializes items if they are being accessed - Make the ISessionSerializer internal as its not the preferred way to serialize session state (use --- designs/session-serialization.md | 85 ++++++ .../Serialization/ISessionSerializer.cs | 14 + .../Serialization/ISessionStateChangeset.cs | 11 + .../Serialization/SessionItemChangeState.cs | 13 + .../Serialization/SessionSerializerOptions.cs | 2 +- .../Serialization/SessionStateChangeItem.cs | 34 +++ .../SessionState/SessionStateExtensions.cs | 37 ++- ...BinarySessionSerializer.ChangesetWriter.cs | 118 ++++++++ .../BinarySessionSerializer.StateWriter.cs | 129 +++++++++ .../SessionState/BinarySessionSerializer.cs | 144 ++-------- .../BinaryWriterReaderExtensions.cs | 45 +++ .../SessionState/SessionStateCollection.cs | 215 ++++++++++++++ .../BinarySessionSerializerTests.cs | 264 ++++++++++++++++-- 13 files changed, 954 insertions(+), 157 deletions(-) create mode 100644 designs/session-serialization.md create mode 100644 src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/ISessionStateChangeset.cs create mode 100644 src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/SessionItemChangeState.cs create mode 100644 src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/SessionStateChangeItem.cs create mode 100644 src/Services/SessionState/BinarySessionSerializer.ChangesetWriter.cs create mode 100644 src/Services/SessionState/BinarySessionSerializer.StateWriter.cs rename src/{Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/Serialization => Services/SessionState}/BinaryWriterReaderExtensions.cs (68%) create mode 100644 src/Services/SessionState/SessionStateCollection.cs diff --git a/designs/session-serialization.md b/designs/session-serialization.md new file mode 100644 index 0000000000..ee7cfaec9e --- /dev/null +++ b/designs/session-serialization.md @@ -0,0 +1,85 @@ +# Session serialization + +Session serialization is provided through the `ISessionSerializer` type. There are two modes that are available: + +## Common structure + +```mermaid +packet-beta +0: "M" +1-10: "Session Id (Variable length)" +11: "N" +12: "A" +13: "R" +14: "T" +15: "C" +16-24: "Key 1 Blob" +25-33: "Key 2 Blob" +34-42: "..." +43-50: "Flags (variable)" +``` + +Where: +- *M*: Mode +- *N*: New session +- *A*: Abandoned +- *R*: Readonly +- *T*: Timeout +- *C*: Key count + +## 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` + +Where `C` is the count of flags, and each `Fn` is a flag identifier an int with 7bit encoding. Each f + +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" +``` + +## 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/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/ISessionSerializer.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/ISessionSerializer.cs index 75f79a71c5..5d04363551 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/ISessionSerializer.cs +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/ISessionSerializer.cs @@ -9,7 +9,21 @@ namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; public 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); + /// + /// Serializes the session state. If the implements it will serialize it + /// in a mode that only tracks the changes that have occurred. + /// + /// + /// + /// + /// Task SerializeAsync(ISessionState state, Stream stream, CancellationToken token); } diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/ISessionStateChangeset.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/ISessionStateChangeset.cs new file mode 100644 index 0000000000..b7c5ec05de --- /dev/null +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/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; + +public interface ISessionStateChangeset : ISessionState +{ + IEnumerable Changes { get; } +} diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/SessionItemChangeState.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/SessionItemChangeState.cs new file mode 100644 index 0000000000..6713bbb7be --- /dev/null +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/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; + +public enum SessionItemChangeState +{ + Unknown = 0, + NoChange = 1, + Removed = 2, + Changed = 3, + New = 4, +} diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/SessionSerializerOptions.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/SessionSerializerOptions.cs index 1ac31c1604..61de2b387e 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.Abstractions/SessionState/Serialization/SessionStateChangeItem.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/SessionStateChangeItem.cs new file mode 100644 index 0000000000..7cbf6410b7 --- /dev/null +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/SessionStateChangeItem.cs @@ -0,0 +1,34 @@ +// 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}")] +public 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() + => State.GetHashCode() ^ Key.GetHashCode(); + + 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/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/SessionStateExtensions.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/SessionStateExtensions.cs index 05c3242d76..48ca881672 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/SessionStateExtensions.cs +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/SessionStateExtensions.cs @@ -3,6 +3,7 @@ using System; using System.Web; +using Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.RemoteSession; @@ -22,11 +23,43 @@ public static void CopyTo(this ISessionState result, HttpSessionStateBase state) } state.Timeout = result.Timeout; + + if (result is ISessionStateChangeset changes) + { + UpdateFromChanges(changes, state); + } + else + { + Replace(result, state); + } + } + + private static void UpdateFromChanges(ISessionStateChangeset from, 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) + { + + } + } + } + + 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/BinarySessionSerializer.ChangesetWriter.cs b/src/Services/SessionState/BinarySessionSerializer.ChangesetWriter.cs new file mode 100644 index 0000000000..645eb8d3d6 --- /dev/null +++ b/src/Services/SessionState/BinarySessionSerializer.ChangesetWriter.cs @@ -0,0 +1,118 @@ +// 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; + +[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 readonly struct ChangesetWriter(ISessionKeySerializer serializer) + { + public List? Write(ISessionStateChangeset state, BinaryWriter writer) + { + writer.Write(ModeDelta); + 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.Changes) + { + writer.Write(item.Key); + + // New with V2 serializer + if (item.State is SessionItemChangeState.NoChange or SessionItemChangeState.Removed) + { + writer.Write7BitEncodedInt((int)item.State); + } + else if (serializer.TrySerialize(item.Key, state[item.Key], out var result)) + { + writer.Write7BitEncodedInt((int)item.State); + writer.Write7BitEncodedInt(result.Length); + writer.Write(result); + } + else + { + (unknownKeys ??= []).Add(item.Key); + writer.Write7BitEncodedInt((int)SessionItemChangeState.Unknown); + } + } + + writer.WriteFlags([]); + + return unknownKeys; + } + + 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(); + + var count = reader.Read7BitEncodedInt(); + + for (var index = count; index > 0; index--) + { + var key = reader.ReadString(); + var changeState = (SessionItemChangeState)reader.Read7BitEncodedInt(); + + if (changeState is SessionItemChangeState.NoChange) + { + state.MarkUnchanged(key); + } + else if (changeState is SessionItemChangeState.Removed) + { + state.MarkRemoved(key); + } + else if (changeState is SessionItemChangeState.Unknown) + { + state.AddUnknownKey(key); + } + else if (changeState is SessionItemChangeState.New or SessionItemChangeState.Changed) + { + 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 + { + state.AddUnknownKey(key); + } + } + } + + foreach (var (flag, payload) in reader.ReadFlags()) + { + // No flags are currently read + } + + return state; + } + } +} diff --git a/src/Services/SessionState/BinarySessionSerializer.StateWriter.cs b/src/Services/SessionState/BinarySessionSerializer.StateWriter.cs new file mode 100644 index 0000000000..f842dfe183 --- /dev/null +++ b/src/Services/SessionState/BinarySessionSerializer.StateWriter.cs @@ -0,0 +1,129 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +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; + +[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 readonly struct StateWriter(ISessionKeySerializer serializer) + { + private const int FLAG_DIFF_REQUESTED = 100; + + public List? Write(ISessionState state, BinaryWriter writer) + { + writer.Write(ModeState); + 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); + } + } + + writer.WriteFlags( + [ + (FLAG_DIFF_REQUESTED, Array.Empty()) + ]); + + + return unknownKeys; + } + + + 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.SetItem(key, bytes); + } + + var unknown = reader.Read7BitEncodedInt(); + + if (unknown > 0) + { + for (var index = unknown; index > 0; index--) + { + state.AddUnknownKey(reader.ReadString()); + } + } + + // Originally this was the end of the data. Now, we have an optional set of flags, but we can stop if there is no more data + if (reader.PeekChar() != -1) + { + foreach (var (flag, payload) in reader.ReadFlags()) + { + HandleFlag(ref state, flag); + } + } + + return state; + } + + private static void HandleFlag(ref SessionStateCollection state, int flag) + { + if (flag == FLAG_DIFF_REQUESTED) + { + state = state.WithTracking(); + } + } + } +} diff --git a/src/Services/SessionState/BinarySessionSerializer.cs b/src/Services/SessionState/BinarySessionSerializer.cs index 2d7481075e..4d21c1aa9d 100644 --- a/src/Services/SessionState/BinarySessionSerializer.cs +++ b/src/Services/SessionState/BinarySessionSerializer.cs @@ -16,7 +16,8 @@ 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; + private const byte ModeState = 1; + private const byte ModeDelta = 2; private readonly SessionSerializerOptions _options; private readonly ISessionKeySerializer _serializer; @@ -37,52 +38,21 @@ public BinarySessionSerializer(ICompositeSessionKeySerializer serializer, IOptio public void Write(ISessionState state, BinaryWriter writer) { - writer.Write(Version); - writer.Write(state.SessionID); + var unknownKeys = state is ISessionStateChangeset delta + ? new ChangesetWriter(_serializer).Write(delta, writer) + : new StateWriter(_serializer).Write(state, writer); - 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) + if (unknownKeys is { }) { - 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); + if (_options.ThrowOnUnknownSessionKey) + { + throw new UnknownSessionKeyException(unknownKeys); + } } } @@ -93,12 +63,14 @@ 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 version = reader.ReadByte(); - var state = new BinaryReaderSerializedSessionState(reader, _serializer); + var state = version switch + { + ModeState => new StateWriter(_serializer).Read(reader), + ModeDelta => new ChangesetWriter(_serializer).Read(reader), + _ => throw new InvalidOperationException("Serialized session state has unknown version.") + }; if (state.UnknownKeys is { Count: > 0 } unknownKeys) { @@ -116,6 +88,7 @@ public ISessionState Read(BinaryReader reader) return state; } + public Task DeserializeAsync(Stream stream, CancellationToken token) { using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: true); @@ -131,85 +104,4 @@ public Task SerializeAsync(ISessionState state, Stream stream, CancellationToken return Task.CompletedTask; } - - private class BinaryReaderSerializedSessionState : ISessionState - { - public BinaryReaderSerializedSessionState(BinaryReader reader, ISessionKeySerializer serializer) - { - 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--) - { - 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); - } - } - - var unknown = reader.Read7BitEncodedInt(); - - if (unknown > 0) - { - for (var index = unknown; index > 0; index--) - { - (UnknownKeys ??= new()).Add(reader.ReadString()); - } - } - } - - private Dictionary? _items; - - public object? this[string key] - { - get => _items?.TryGetValue(key, out var result) is true ? result : null; - set => (_items ??= new())[key] = value; - } - - 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() - { - } - } } diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/Serialization/BinaryWriterReaderExtensions.cs b/src/Services/SessionState/BinaryWriterReaderExtensions.cs similarity index 68% rename from src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/Serialization/BinaryWriterReaderExtensions.cs rename to src/Services/SessionState/BinaryWriterReaderExtensions.cs index e684db6503..abe668cc3f 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/Serialization/BinaryWriterReaderExtensions.cs +++ b/src/Services/SessionState/BinaryWriterReaderExtensions.cs @@ -1,8 +1,14 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; using System.Runtime.CompilerServices; +using FlagEntry = (int Flag, System.ReadOnlyMemory Payload); + namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; /// @@ -10,6 +16,44 @@ namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; /// internal static class BinaryWriterReaderExtensions { + internal static void WriteFlags(this BinaryWriter writer, ReadOnlySpan flagEntries) + { + writer.Write7BitEncodedInt(flagEntries.Length); + + foreach (var (flag, payload) in flagEntries) + { + writer.Write7BitEncodedInt(flag); + writer.Write7BitEncodedInt(payload.Length); + writer.Write(payload.Span); + } + } + + internal static IEnumerable ReadFlags(this BinaryReader reader) + { + var length = reader.Read7BitEncodedInt(); + + while (length > 0) + { + var flag = reader.Read7BitEncodedInt(); + var payloadLength = reader.Read7BitEncodedInt(); + var payload = reader.ReadBytes(payloadLength); + + yield return new(flag, payload); + + length--; + } + } + +#if NETFRAMEWORK + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Write(this BinaryWriter writer, ReadOnlySpan bytes) + { + var array = ArrayPool.Shared.Rent(bytes.Length); + bytes.CopyTo(array); + writer.Write(array, 0, bytes.Length); + ArrayPool.Shared.Return(array); + } + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void Write7BitEncodedInt(this BinaryWriter writer, int value) @@ -76,5 +120,6 @@ public static int Read7BitEncodedInt(this BinaryReader reader) result |= (uint)byteReadJustNow << (MaxBytesWithoutOverflow * 7); return (int)result; } +#endif } diff --git a/src/Services/SessionState/SessionStateCollection.cs b/src/Services/SessionState/SessionStateCollection.cs new file mode 100644 index 0000000000..c6dbaa6ef8 --- /dev/null +++ b/src/Services/SessionState/SessionStateCollection.cs @@ -0,0 +1,215 @@ +// 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 AddUnknownKey(string key) + { + (UnknownKeys ??= new()).Add(key); + } + + public void MarkUnchanged(string key) => _items[key] = new([]); + + public void MarkRemoved(string key) => _items[key] = new((object?)null); + + public void SetItem(string key, byte[] data) => _items[key] = new(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 + { + _items[key] = new(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; + + IEnumerable ISessionState.Keys => _items?.Keys ?? Enumerable.Empty(); + + void ISessionState.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); + } + } + } + + void ISessionState.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; + + public ItemHolder(object? value) + { + _value = value; + IsNew = true; + } + + public ItemHolder(byte[] data) + { + _data = data; + } + + 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; + } + + public void SetValue(object? value) + + { + _value = value; + _data = null; + } + } + + 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 a370522a4b..a352bced1c 100644 --- a/test/Microsoft.AspNetCore.SystemWebAdapters.CoreServices.Tests/SessionState/Serialization/BinarySessionSerializerTests.cs +++ b/test/Microsoft.AspNetCore.SystemWebAdapters.CoreServices.Tests/SessionState/Serialization/BinarySessionSerializerTests.cs @@ -2,10 +2,15 @@ // 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.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Client; using Moq; using Xunit; @@ -27,7 +32,7 @@ public async Task SerializeEmpty() await serializer.SerializeAsync(state.Object, ms, default); // Assert - Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 0, 0 }); + Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 0, 0, 1, 100, 0 }); } [Fact] @@ -67,7 +72,7 @@ public async Task SerializeIsNewSession() await serializer.SerializeAsync(state.Object, ms, default); // Assert - Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 1, 0, 0, 0, 0, 0 }); + Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 1, 0, 0, 0, 0, 0, 1, 100, 0 }); } [Fact] @@ -107,7 +112,7 @@ public async Task SerializeIsAbandoned() await serializer.SerializeAsync(state.Object, ms, default); // Assert - Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 0, 1, 0, 0, 0, 0 }); + Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 0, 1, 0, 0, 0, 0, 1, 100, 0 }); } [Fact] @@ -147,14 +152,18 @@ public async Task SerializeIsReadOnly() await serializer.SerializeAsync(state.Object, ms, default); // Assert - Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 0, 0, 1, 0, 0, 0 }); + Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 0, 0, 1, 0, 0, 0, 1, 100, 0 }); } - [Fact] - public async Task DeserializeIsReadOnly() + [InlineData(FlagOptions.None)] + [InlineData(FlagOptions.Changes)] + [InlineData(FlagOptions.NoChanges)] + [Theory] + public async Task DeserializeIsReadOnly(FlagOptions options) { // Arrange - var data = new byte[] { 1, 2, 105, 100, 0, 0, 1, 0, 0, 0 }; + var data = AddFlags([1, 2, 105, 100, 0, 0, 1, 0, 0, 0], options); + using var ms = new MemoryStream(data); var serializer = CreateSerializer(); @@ -170,13 +179,26 @@ public async Task DeserializeIsReadOnly() Assert.Equal(0, result.Timeout); Assert.Equal(0, result.Count); Assert.Empty(result.Keys); + + if (options is FlagOptions.None or FlagOptions.NoChanges) + { + Assert.IsNotAssignableFrom(result); + } + else + { + Assert.IsAssignableFrom(result); + } } - [Fact] - public async Task DeserializeIsReadOnlyEmptyNull() + [InlineData(FlagOptions.None)] + [InlineData(FlagOptions.Changes)] + [InlineData(FlagOptions.NoChanges)] + [Theory] + public async Task DeserializeIsReadOnlyEmptyNull(FlagOptions options) { // Arrange - var data = new byte[] { 1, 2, 105, 100, 0, 0, 1, 0, 0, 0 }; + + var data = AddFlags([1, 2, 105, 100, 0, 0, 1, 0, 0, 0], options); using var ms = new MemoryStream(data); var serializer = CreateSerializer(); @@ -192,6 +214,15 @@ public async Task DeserializeIsReadOnlyEmptyNull() Assert.Equal(0, result.Timeout); Assert.Equal(0, result.Count); Assert.Empty(result.Keys); + + if (options is FlagOptions.None or FlagOptions.NoChanges) + { + Assert.IsNotAssignableFrom(result); + } + else + { + Assert.IsAssignableFrom(result); + } } [Fact] @@ -209,14 +240,17 @@ public async Task SerializeTimeout() await serializer.SerializeAsync(state.Object, ms, default); // Assert - Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 0, 0, 0, 20, 0, 0 }); + Assert.Equal(new byte[] { 1, 2, 105, 100, 0, 0, 0, 20, 0, 0, 1, 100, 0 }, ms.ToArray()); } - [Fact] - public async Task DeserializeTimeout() + [InlineData(FlagOptions.None)] + [InlineData(FlagOptions.Changes)] + [InlineData(FlagOptions.NoChanges)] + [Theory] + public async Task DeserializeTimeout(FlagOptions options) { // Arrange - var data = new byte[] { 1, 2, 105, 100, 0, 0, 0, 20, 0, 0 }; + var data = AddFlags([1, 2, 105, 100, 0, 0, 0, 20, 0, 0], options); using var ms = new MemoryStream(data); var serializer = CreateSerializer(); @@ -232,6 +266,15 @@ public async Task DeserializeTimeout() Assert.Equal(20, result.Timeout); Assert.Equal(0, result.Count); Assert.Empty(result.Keys); + + if (options is FlagOptions.None or FlagOptions.NoChanges) + { + Assert.IsNotAssignableFrom(result); + } + else + { + Assert.IsAssignableFrom(result); + } } [Fact] @@ -256,7 +299,7 @@ public async Task Serialize1Key() await serializer.SerializeAsync(state.Object, 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(), new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 42, 0, 1, 100, 0 }); } [Fact] @@ -281,14 +324,17 @@ public async Task Serialize1KeyNull() await serializer.SerializeAsync(state.Object, 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(), new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 0, 0, 1, 100, 0 }); } - [Fact] - public async Task Deserialize1KeyNull() + [InlineData(FlagOptions.None)] + [InlineData(FlagOptions.Changes)] + [InlineData(FlagOptions.NoChanges)] + [Theory] + public async Task Deserialize1KeyNull(FlagOptions options) { // Arrange - var data = new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 0, 0 }; + var data = AddFlags([1, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 0, 0], options); var obj = new object(); var value = new byte[] { 0 }; @@ -309,17 +355,29 @@ public async Task Deserialize1KeyNull() Assert.Equal(1, result.Count); Assert.Same(obj, result["key1"]); Assert.Collection(result.Keys, k => Assert.Equal("key1", k)); + + if (options is FlagOptions.None or FlagOptions.NoChanges) + { + Assert.IsNotAssignableFrom(result); + } + else + { + Assert.IsAssignableFrom(result); + } } - [Fact] - public async Task Deserialize1KeyV1() + [InlineData(FlagOptions.None)] + [InlineData(FlagOptions.Changes)] + [InlineData(FlagOptions.NoChanges)] + [Theory] + public async Task Deserialize1KeyV1(FlagOptions options) { // 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 }; + var data = AddFlags([1, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 0, 0], options); using var ms = new MemoryStream(data); var serializer = CreateSerializer(keySerializer.Object); @@ -336,6 +394,15 @@ public async Task Deserialize1KeyV1() Assert.Equal(1, result.Count); Assert.Equal(result.Keys, new[] { "key1" }); Assert.Equal(obj, result["key1"]); + + if (options is FlagOptions.None or FlagOptions.NoChanges) + { + Assert.IsNotAssignableFrom(result); + } + else + { + Assert.IsAssignableFrom(result); + } } [Fact] @@ -360,11 +427,14 @@ public async Task Serialize1KeyNullable() await serializer.SerializeAsync(state.Object, 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(), new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 0, 0, 1, 100, 0 }); } - [Fact] - public async Task Deserialize1Key() + [InlineData(FlagOptions.None)] + [InlineData(FlagOptions.Changes)] + [InlineData(FlagOptions.NoChanges)] + [Theory] + public async Task Deserialize1Key(FlagOptions options) { // Arrange var obj = new object(); @@ -372,7 +442,7 @@ 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 }; + var data = AddFlags([1, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 42, 0], options); using var ms = new MemoryStream(data); var serializer = CreateSerializer(keySerializer.Object); @@ -389,19 +459,101 @@ public async Task Deserialize1Key() Assert.Equal(1, result.Count); Assert.Equal(result.Keys, new[] { "key1" }); Assert.Equal(obj, result["key1"]); + + if (options is FlagOptions.None or FlagOptions.NoChanges) + { + Assert.IsNotAssignableFrom(result); + } + else + { + Assert.IsAssignableFrom(result); + } } - private static BinarySessionSerializer CreateSerializer(ISessionKeySerializer? keySerializer = null) + [Fact] + public async Task RoundtripDoesntOverwrite() + { + // Arrange + var obj1 = new object(); + var bytes1 = new byte[] { 42 }; + var obj2 = new object(); + var bytes2 = new byte[] { 43 }; + var keySerializer1 = new Mock(); + RegisterKey(keySerializer1, "key1", obj1, bytes1); + + var serializer1 = CreateSerializer(keySerializer1.Object, options => options.ThrowOnUnknownSessionKey = false); + + using var initialState = new TestState() + { + { "key1", obj1 }, + { "key2", obj2 }, + }; + + // Act + var state2 = await RoundtripAsync(serializer1, initialState); + + // Assert + var changeset = Assert.IsAssignableFrom(state2); + + Assert.Collection( + changeset.Changes, + c => + { + Assert.Equal("key1", c.Key); + Assert.Equal(SessionItemChangeState.NoChange, c.State); + }, + c => + { + Assert.Equal("key2", c.Key); + Assert.Equal(SessionItemChangeState.NoChange, c.State); + }); + } + + private static async Task RoundtripAsync(BinarySessionSerializer serializer, ISessionState state) + { + using var ms = new MemoryStream(); + await serializer.SerializeAsync(state, ms, default); + ms.Position = 0; + + var result = await serializer.DeserializeAsync(ms, default); + + return result!; + } + + private static void RegisterKey(Mock keySerializer, string name, object? obj, byte[] data) + { + keySerializer.Setup(k => k.TryDeserialize(name, data, out obj)).Returns(true); + keySerializer.Setup(k => k.TrySerialize(name, obj, out data)).Returns(true); + } + + private static BinarySessionSerializer CreateSerializer(ISessionKeySerializer? keySerializer = null, Action? optionsConfigure = null) { keySerializer ??= new Mock().Object; var logger = new Mock>(); var optionContainer = new Mock>(); - optionContainer.Setup(o => o.Value).Returns(new SessionSerializerOptions()); + var options = new SessionSerializerOptions(); + optionsConfigure?.Invoke(options); + optionContainer.Setup(o => o.Value).Returns(options); return new BinarySessionSerializer(new Composite(keySerializer), optionContainer.Object, logger.Object); } + public enum FlagOptions + { + None = 0, + NoChanges = 1, + Changes = 2, + } + + private static byte[] AddFlags(byte[] data, FlagOptions options) => options switch + { + FlagOptions.None => data, + FlagOptions.Changes => [.. data, 1, 100, 0], + FlagOptions.NoChanges => [.. data, 0], + _ => throw new ArgumentOutOfRangeException(nameof(options)), + }; + private sealed class Composite : ICompositeSessionKeySerializer { private readonly ISessionKeySerializer _serializer; @@ -417,4 +569,60 @@ public bool TryDeserialize(string key, byte[] bytes, out object? obj) public bool TrySerialize(string key, object? value, out byte[] bytes) => _serializer.TrySerialize(key, value, out bytes); } + + private sealed class TestState : ISessionState, IEnumerable> + { + private readonly Dictionary _items = []; + + public object? this[string key] + { + get => _items.TryGetValue(key, out var value) ? value : null; + set + { + if (value is null) + { + _items.Remove(key); + } + else + { + _items[key] = value; + } + } + } + + public void Add(string key, object value) + { + _items.Add(key, value); + } + + public string SessionID => "id"; + + public bool IsReadOnly => false; + + public int Timeout { get; set; } + + public bool IsNewSession => true; + + public int Count => _items.Count; + + public bool IsSynchronized => false; + + public object SyncRoot => _items; + + public bool IsAbandoned { get; set; } + + public IEnumerable Keys => _items.Keys; + + public void Clear() => _items.Clear(); + public Task CommitAsync(CancellationToken token) => Task.CompletedTask; + public void Dispose() + { + } + + public void Remove(string key) => _items.Remove(key); + + public IEnumerator> GetEnumerator() => _items.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } } From 897544cd77e577fa0218933ab098a1cb244a68da Mon Sep 17 00:00:00 2001 From: Taylor Southwick Date: Tue, 7 Jan 2025 10:12:32 -0800 Subject: [PATCH 02/10] make ISessionSerializer internal --- .../SessionState}/ISessionSerializer.cs | 2 +- .../SessionState}/ISessionStateChangeset.cs | 4 ++-- .../SessionState}/SessionItemChangeState.cs | 4 ++-- .../SessionState}/SessionStateChangeItem.cs | 15 ++++++++++++--- 4 files changed, 17 insertions(+), 8 deletions(-) rename src/{Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization => Services/SessionState}/ISessionSerializer.cs (96%) rename src/{Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization => Services/SessionState}/ISessionStateChangeset.cs (66%) rename src/{Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization => Services/SessionState}/SessionItemChangeState.cs (69%) rename src/{Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization => Services/SessionState}/SessionStateChangeItem.cs (65%) diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/ISessionSerializer.cs b/src/Services/SessionState/ISessionSerializer.cs similarity index 96% rename from src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/ISessionSerializer.cs rename to src/Services/SessionState/ISessionSerializer.cs index 5d04363551..297c36e73f 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/ISessionSerializer.cs +++ b/src/Services/SessionState/ISessionSerializer.cs @@ -7,7 +7,7 @@ namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; -public interface ISessionSerializer +internal interface ISessionSerializer { /// /// Deserializes a session state. diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/ISessionStateChangeset.cs b/src/Services/SessionState/ISessionStateChangeset.cs similarity index 66% rename from src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/ISessionStateChangeset.cs rename to src/Services/SessionState/ISessionStateChangeset.cs index b7c5ec05de..4d00579b32 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/ISessionStateChangeset.cs +++ b/src/Services/SessionState/ISessionStateChangeset.cs @@ -1,11 +1,11 @@ -// 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 System.Collections.Generic; namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; -public interface ISessionStateChangeset : ISessionState +internal interface ISessionStateChangeset : ISessionState { IEnumerable Changes { get; } } diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/SessionItemChangeState.cs b/src/Services/SessionState/SessionItemChangeState.cs similarity index 69% rename from src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/SessionItemChangeState.cs rename to src/Services/SessionState/SessionItemChangeState.cs index 6713bbb7be..caae0efecd 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/SessionItemChangeState.cs +++ b/src/Services/SessionState/SessionItemChangeState.cs @@ -1,9 +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. namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; -public enum SessionItemChangeState +internal enum SessionItemChangeState { Unknown = 0, NoChange = 1, diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/SessionStateChangeItem.cs b/src/Services/SessionState/SessionStateChangeItem.cs similarity index 65% rename from src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/SessionStateChangeItem.cs rename to src/Services/SessionState/SessionStateChangeItem.cs index 7cbf6410b7..056a5119fd 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/SessionStateChangeItem.cs +++ b/src/Services/SessionState/SessionStateChangeItem.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. using System; @@ -7,7 +7,7 @@ namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; [DebuggerDisplay("{State}: {Key,nq}")] -public readonly struct SessionStateChangeItem(SessionItemChangeState state, string key) : IEquatable +internal readonly struct SessionStateChangeItem(SessionItemChangeState state, string key) : IEquatable { public SessionItemChangeState State => state; @@ -16,7 +16,16 @@ public readonly struct SessionStateChangeItem(SessionItemChangeState state, stri public override bool Equals(object? obj) => obj is SessionStateChangeItem item && Equals(item); public override int GetHashCode() - => State.GetHashCode() ^ Key.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 From 34ae8fc94acd4278f1fe785ef7ac2814ea4e87e3 Mon Sep 17 00:00:00 2001 From: Taylor Southwick Date: Tue, 7 Jan 2025 15:30:41 -0800 Subject: [PATCH 03/10] Add some tests and respond to feedback --- .../Serialization/SessionSerializerOptions.cs | 5 + .../RemoteSession/InMemoryLockedSessions.cs | 7 +- .../RemoteSession/ReadWriteSessionHandler.cs | 8 +- .../RemoteSession/RemoteSessionModule.cs | 5 +- .../SessionState/SessionStateExtensions.cs | 14 +- ...BinarySessionSerializer.ChangesetWriter.cs | 11 +- .../BinarySessionSerializer.StateWriter.cs | 33 +- .../SessionState/BinarySessionSerializer.cs | 55 +--- .../CompositeSessionKeySerializer.cs | 30 +- .../SessionState/SessionStateCollection.cs | 45 +-- .../BinarySessionSerializerTests.cs | 144 +++++---- .../SessionStateCollectionTests.cs | 288 ++++++++++++++++++ .../CompositeSessionKeySerializerTests.cs | 11 +- ...WebAdapters.FrameworkServices.Tests.csproj | 1 + .../RemoteSession/RemoteSessionModuleTests.cs | 4 +- 15 files changed, 503 insertions(+), 158 deletions(-) create mode 100644 test/Microsoft.AspNetCore.SystemWebAdapters.CoreServices.Tests/SessionState/Serialization/SessionStateCollectionTests.cs diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/SessionSerializerOptions.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/SessionSerializerOptions.cs index 61de2b387e..77c4b166fc 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/SessionSerializerOptions.cs +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/SessionSerializerOptions.cs @@ -9,4 +9,9 @@ public class SessionSerializerOptions /// Gets or sets whether an exception should be thrown if an unknown session key is encountered. /// public bool ThrowOnUnknownSessionKey { get; set; } = true; + + /// + /// Gets or sets whether changes to session state are supported. This requires both the server and clients to be on v2.0+ of the adapters. + /// + public bool EnableChangeTracking { get; set; } = true; } diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/InMemoryLockedSessions.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/InMemoryLockedSessions.cs index a45f35c115..280d3b0090 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/ReadWriteSessionHandler.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/ReadWriteSessionHandler.cs index 2547ed8863..2d37da96df 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/ReadWriteSessionHandler.cs +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/ReadWriteSessionHandler.cs @@ -6,16 +6,19 @@ 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 { 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) @@ -50,6 +53,7 @@ private async Task SendSessionAsync(HttpContextBase context, CancellationToken t await _serializer.SerializeAsync(wrapper, context.Response.OutputStream, token); // Ensure to call HttpResponse.FlushAsync to flush the request itself, and not context.Response.OutputStream.FlushAsync() + await context.Response.OutputStream.FlushAsync(token); await context.Response.FlushAsync(); } @@ -62,7 +66,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 aae8ccefeb..e124e0f096 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,7 +24,7 @@ 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); diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/SessionStateExtensions.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/SessionStateExtensions.cs index 48ca881672..b32ae31b86 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/SessionStateExtensions.cs +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/SessionStateExtensions.cs @@ -4,12 +4,16 @@ 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)) { @@ -26,7 +30,7 @@ public static void CopyTo(this ISessionState result, HttpSessionStateBase state) if (result is ISessionStateChangeset changes) { - UpdateFromChanges(changes, state); + UpdateFromChanges(changes, logger, state); } else { @@ -34,7 +38,7 @@ public static void CopyTo(this ISessionState result, HttpSessionStateBase state) } } - private static void UpdateFromChanges(ISessionStateChangeset from, HttpSessionStateBase state) + private static void UpdateFromChanges(ISessionStateChangeset from, ILogger logger, HttpSessionStateBase state) { foreach (var change in from.Changes) { @@ -48,7 +52,7 @@ private static void UpdateFromChanges(ISessionStateChangeset from, HttpSessionSt } else if (change.State is SessionItemChangeState.Unknown) { - + LogUnknownSessionKey(logger, change.Key); } } } diff --git a/src/Services/SessionState/BinarySessionSerializer.ChangesetWriter.cs b/src/Services/SessionState/BinarySessionSerializer.ChangesetWriter.cs index 645eb8d3d6..7eb85837d0 100644 --- a/src/Services/SessionState/BinarySessionSerializer.ChangesetWriter.cs +++ b/src/Services/SessionState/BinarySessionSerializer.ChangesetWriter.cs @@ -18,7 +18,7 @@ internal partial class BinarySessionSerializer : ISessionSerializer { private readonly struct ChangesetWriter(ISessionKeySerializer serializer) { - public List? Write(ISessionStateChangeset state, BinaryWriter writer) + public void Write(ISessionStateChangeset state, BinaryWriter writer) { writer.Write(ModeDelta); writer.Write(state.SessionID); @@ -30,8 +30,6 @@ private readonly struct ChangesetWriter(ISessionKeySerializer serializer) writer.Write7BitEncodedInt(state.Timeout); writer.Write7BitEncodedInt(state.Count); - List? unknownKeys = null; - foreach (var item in state.Changes) { writer.Write(item.Key); @@ -49,14 +47,11 @@ private readonly struct ChangesetWriter(ISessionKeySerializer serializer) } else { - (unknownKeys ??= []).Add(item.Key); writer.Write7BitEncodedInt((int)SessionItemChangeState.Unknown); } } writer.WriteFlags([]); - - return unknownKeys; } public SessionStateCollection Read(BinaryReader reader) @@ -86,7 +81,7 @@ public SessionStateCollection Read(BinaryReader reader) } else if (changeState is SessionItemChangeState.Unknown) { - state.AddUnknownKey(key); + state.SetUnknownKey(key); } else if (changeState is SessionItemChangeState.New or SessionItemChangeState.Changed) { @@ -102,7 +97,7 @@ public SessionStateCollection Read(BinaryReader reader) } else { - state.AddUnknownKey(key); + state.SetUnknownKey(key); } } } diff --git a/src/Services/SessionState/BinarySessionSerializer.StateWriter.cs b/src/Services/SessionState/BinarySessionSerializer.StateWriter.cs index f842dfe183..414ff3af0d 100644 --- a/src/Services/SessionState/BinarySessionSerializer.StateWriter.cs +++ b/src/Services/SessionState/BinarySessionSerializer.StateWriter.cs @@ -9,6 +9,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.SystemWebAdapters.SessionState.RemoteSession; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -17,13 +18,13 @@ 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 readonly struct StateWriter(ISessionKeySerializer serializer) + private readonly struct StateWriter(ISessionKeySerializer serializer, byte mode) { - private const int FLAG_DIFF_REQUESTED = 100; + private const int FLAG_DIFF_SUPPORTED = 100; - public List? Write(ISessionState state, BinaryWriter writer) + public void Write(ISessionState state, BinaryWriter writer) { - writer.Write(ModeState); + writer.Write(mode); writer.Write(state.SessionID); writer.Write(state.IsNewSession); @@ -65,16 +66,15 @@ private readonly struct StateWriter(ISessionKeySerializer serializer) } } - writer.WriteFlags( - [ - (FLAG_DIFF_REQUESTED, Array.Empty()) - ]); - - - return unknownKeys; + if (mode == ModeStateV2) + { + writer.WriteFlags( + [ + (FLAG_DIFF_SUPPORTED, Array.Empty()) + ]); + } } - public SessionStateCollection Read(BinaryReader reader) { var state = new SessionStateCollection(serializer); @@ -93,7 +93,7 @@ public SessionStateCollection Read(BinaryReader reader) var length = reader.Read7BitEncodedInt(); var bytes = reader.ReadBytes(length); - state.SetItem(key, bytes); + state.SetData(key, bytes); } var unknown = reader.Read7BitEncodedInt(); @@ -102,12 +102,11 @@ public SessionStateCollection Read(BinaryReader reader) { for (var index = unknown; index > 0; index--) { - state.AddUnknownKey(reader.ReadString()); + state.SetUnknownKey(reader.ReadString()); } } - // Originally this was the end of the data. Now, we have an optional set of flags, but we can stop if there is no more data - if (reader.PeekChar() != -1) + if (mode == ModeStateV2) { foreach (var (flag, payload) in reader.ReadFlags()) { @@ -120,7 +119,7 @@ public SessionStateCollection Read(BinaryReader reader) private static void HandleFlag(ref SessionStateCollection state, int flag) { - if (flag == FLAG_DIFF_REQUESTED) + if (flag == FLAG_DIFF_SUPPORTED) { state = state.WithTracking(); } diff --git a/src/Services/SessionState/BinarySessionSerializer.cs b/src/Services/SessionState/BinarySessionSerializer.cs index 4d21c1aa9d..9146b632ef 100644 --- a/src/Services/SessionState/BinarySessionSerializer.cs +++ b/src/Services/SessionState/BinarySessionSerializer.cs @@ -16,43 +16,30 @@ 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 ModeState = 1; - private const byte ModeDelta = 2; + private const byte ModeStateV1 = 1; + private const byte ModeStateV2 = 2; + private const byte ModeDelta = 3; - private readonly SessionSerializerOptions _options; + private readonly IOptions _options; private readonly ISessionKeySerializer _serializer; private readonly ILogger _logger; 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) { - var unknownKeys = state is ISessionStateChangeset delta - ? new ChangesetWriter(_serializer).Write(delta, writer) - : new StateWriter(_serializer).Write(state, writer); - - if (unknownKeys is { }) + if (state is ISessionStateChangeset delta) { - foreach (var key in unknownKeys) - { - LogSerialization(key); - } - - if (_options.ThrowOnUnknownSessionKey) - { - throw new UnknownSessionKeyException(unknownKeys); - } + new ChangesetWriter(_serializer).Write(delta, writer); + } + else + { + new StateWriter(_serializer, _options.Value.EnableChangeTracking ? ModeStateV2 : ModeStateV1).Write(state, writer); } } @@ -65,27 +52,13 @@ public ISessionState Read(BinaryReader reader) var version = reader.ReadByte(); - var state = version switch + return version switch { - ModeState => new StateWriter(_serializer).Read(reader), + ModeStateV1 => new StateWriter(_serializer, ModeStateV1).Read(reader), + ModeStateV2 => new StateWriter(_serializer, ModeStateV2).Read(reader), ModeDelta => new ChangesetWriter(_serializer).Read(reader), _ => throw new InvalidOperationException("Serialized session state has unknown version.") }; - - if (state.UnknownKeys is { Count: > 0 } unknownKeys) - { - foreach (var unknown in unknownKeys) - { - LogDeserialization(unknown); - } - - if (_options.ThrowOnUnknownSessionKey) - { - throw new UnknownSessionKeyException(unknownKeys); - } - } - - return state; } diff --git a/src/Services/SessionState/CompositeSessionKeySerializer.cs b/src/Services/SessionState/CompositeSessionKeySerializer.cs index 1233632f68..3eadf19c4a 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/SessionStateCollection.cs b/src/Services/SessionState/SessionStateCollection.cs index c6dbaa6ef8..3c089706c8 100644 --- a/src/Services/SessionState/SessionStateCollection.cs +++ b/src/Services/SessionState/SessionStateCollection.cs @@ -44,16 +44,17 @@ public static SessionStateCollection CreateTracking(ISessionKeySerializer serial public ISessionKeySerializer Serializer { get; } - public void AddUnknownKey(string key) + public void SetUnknownKey(string key) { (UnknownKeys ??= new()).Add(key); + _items.Remove(key); } - public void MarkUnchanged(string key) => _items[key] = new([]); + public void MarkUnchanged(string key) => _items[key] = ItemHolder.Unchanged(); - public void MarkRemoved(string key) => _items[key] = new((object?)null); + public void MarkRemoved(string key) => _items[key] = ItemHolder.Removed(); - public void SetItem(string key, byte[] data) => _items[key] = new(data); + public void SetData(string key, byte[] data) => _items[key] = ItemHolder.FromData(data); public object? this[string key] { @@ -64,9 +65,9 @@ public object? this[string key] { existing.SetValue(value); } - else + else if (value is { }) { - _items[key] = new(value); + _items[key] = ItemHolder.NewValue(value); } } } @@ -100,9 +101,9 @@ public IEnumerable Changes object ISessionState.SyncRoot => this; - IEnumerable ISessionState.Keys => _items?.Keys ?? Enumerable.Empty(); + public IEnumerable Keys => _items?.Keys ?? Enumerable.Empty(); - void ISessionState.Clear() + public void Clear() { List? newKeys = null; @@ -127,7 +128,7 @@ void ISessionState.Clear() } } - void ISessionState.Remove(string key) + public void Remove(string key) { if (_items.TryGetValue(key, out var existing)) { @@ -153,15 +154,9 @@ private sealed class ItemHolder private byte[]? _data; private object? _value; - public ItemHolder(object? value) + private ItemHolder(bool isNew = false) { - _value = value; - IsNew = true; - } - - public ItemHolder(byte[] data) - { - _data = data; + IsNew = isNew; } public bool IsNew { get; } @@ -185,19 +180,27 @@ public ItemHolder(byte[] data) if (_data is { } data && serializer.TryDeserialize(key, data, out var obj)) { _value = obj; + _data = null; } - _data = null; - return _value; } - public void SetValue(object? 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 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 a352bced1c..d33ba82e8b 100644 --- a/test/Microsoft.AspNetCore.SystemWebAdapters.CoreServices.Tests/SessionState/Serialization/BinarySessionSerializerTests.cs +++ b/test/Microsoft.AspNetCore.SystemWebAdapters.CoreServices.Tests/SessionState/Serialization/BinarySessionSerializerTests.cs @@ -6,6 +6,7 @@ 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; @@ -18,11 +19,16 @@ namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization.Test public class BinarySessionSerializerTests { - [Fact] - public async Task SerializeEmpty() + private const byte ModeStateV1 = 1; + private const byte ModeStateV2 = 2; + + [InlineData(true)] + [InlineData(false)] + [Theory] + public async Task SerializeEmpty(bool trackChanges) { // Arrange - var serializer = CreateSerializer(); + var serializer = CreateSerializer(trackChanges); using var ms = new MemoryStream(); var state = new Mock(); @@ -32,17 +38,19 @@ public async Task SerializeEmpty() await serializer.SerializeAsync(state.Object, ms, default); // Assert - Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 0, 0, 1, 100, 0 }); + Assert.Equal(ms.ToArray(), AddFlags([2, 105, 100, 0, 0, 0, 0, 0, 0], trackChanges)); } - [Fact] - public async Task DeserializeEmpty() + [InlineData(true)] + [InlineData(false)] + [Theory] + public async Task DeserializeEmpty(bool trackChanges) { // Arrange - var data = new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 0, 0 }; + var data = AddFlags([2, 105, 100, 0, 0, 0, 0, 0, 0], trackChanges); using var ms = new MemoryStream(data); - var serializer = CreateSerializer(); + var serializer = CreateSerializer(trackChanges); // Act var result = await serializer.DeserializeAsync(ms, default); @@ -57,11 +65,13 @@ public async Task DeserializeEmpty() Assert.Empty(result.Keys); } - [Fact] - public async Task SerializeIsNewSession() + [InlineData(true)] + [InlineData(false)] + [Theory] + public async Task SerializeIsNewSession(bool trackChanges) { // Arrange - var serializer = CreateSerializer(); + var serializer = CreateSerializer(null, trackChanges); using var ms = new MemoryStream(); var state = new Mock(); @@ -72,14 +82,16 @@ public async Task SerializeIsNewSession() await serializer.SerializeAsync(state.Object, ms, default); // Assert - Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 1, 0, 0, 0, 0, 0, 1, 100, 0 }); + Assert.Equal(ms.ToArray(), AddFlags([2, 105, 100, 1, 0, 0, 0, 0, 0], trackChanges)); } - [Fact] - public async Task DeserializeIsNewSession() + [InlineData(true)] + [InlineData(false)] + [Theory] + public async Task DeserializeIsNewSession(bool trackChanges) { // Arrange - var data = new byte[] { 1, 2, 105, 100, 1, 0, 0, 0, 0, 0 }; + var data = AddFlags([2, 105, 100, 1, 0, 0, 0, 0, 0], trackChanges); using var ms = new MemoryStream(data); var serializer = CreateSerializer(); @@ -97,11 +109,13 @@ public async Task DeserializeIsNewSession() Assert.Empty(result.Keys); } - [Fact] - public async Task SerializeIsAbandoned() + [InlineData(true)] + [InlineData(false)] + [Theory] + public async Task SerializeIsAbandoned(bool trackChanges) { // Arrange - var serializer = CreateSerializer(); + var serializer = CreateSerializer(trackChanges); using var ms = new MemoryStream(); var state = new Mock(); @@ -112,14 +126,16 @@ public async Task SerializeIsAbandoned() await serializer.SerializeAsync(state.Object, ms, default); // Assert - Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 0, 1, 0, 0, 0, 0, 1, 100, 0 }); + Assert.Equal(ms.ToArray(), AddFlags([2, 105, 100, 0, 1, 0, 0, 0, 0], trackChanges)); } - [Fact] - public async Task DeserializeIsAbandoned() + [InlineData(true)] + [InlineData(false)] + [Theory] + public async Task DeserializeIsAbandoned(bool trackChanges) { // Arrange - var data = new byte[] { 1, 2, 105, 100, 0, 1, 0, 0, 0, 0 }; + var data = AddFlags([2, 105, 100, 0, 1, 0, 0, 0, 0], trackChanges); using var ms = new MemoryStream(data); var serializer = CreateSerializer(); @@ -137,11 +153,13 @@ public async Task DeserializeIsAbandoned() Assert.Empty(result.Keys); } - [Fact] - public async Task SerializeIsReadOnly() + [InlineData(true)] + [InlineData(false)] + [Theory] + public async Task SerializeIsReadOnly(bool trackChanges) { // Arrange - var serializer = CreateSerializer(); + var serializer = CreateSerializer(trackChanges); using var ms = new MemoryStream(); var state = new Mock(); @@ -152,7 +170,7 @@ public async Task SerializeIsReadOnly() await serializer.SerializeAsync(state.Object, ms, default); // Assert - Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 0, 0, 1, 0, 0, 0, 1, 100, 0 }); + Assert.Equal(ms.ToArray(), AddFlags([2, 105, 100, 0, 0, 1, 0, 0, 0], trackChanges)); } [InlineData(FlagOptions.None)] @@ -162,7 +180,7 @@ public async Task SerializeIsReadOnly() public async Task DeserializeIsReadOnly(FlagOptions options) { // Arrange - var data = AddFlags([1, 2, 105, 100, 0, 0, 1, 0, 0, 0], options); + var data = AddFlags([2, 105, 100, 0, 0, 1, 0, 0, 0], options); using var ms = new MemoryStream(data); @@ -198,7 +216,7 @@ public async Task DeserializeIsReadOnlyEmptyNull(FlagOptions options) { // Arrange - var data = AddFlags([1, 2, 105, 100, 0, 0, 1, 0, 0, 0], options); + var data = AddFlags([2, 105, 100, 0, 0, 1, 0, 0, 0], options); using var ms = new MemoryStream(data); var serializer = CreateSerializer(); @@ -225,11 +243,13 @@ public async Task DeserializeIsReadOnlyEmptyNull(FlagOptions options) } } - [Fact] - public async Task SerializeTimeout() + [InlineData(true)] + [InlineData(false)] + [Theory] + public async Task SerializeTimeout(bool trackChanges) { // Arrange - var serializer = CreateSerializer(); + var serializer = CreateSerializer(keySerializer: null, trackChanges: trackChanges); using var ms = new MemoryStream(); var state = new Mock(); @@ -240,7 +260,7 @@ public async Task SerializeTimeout() await serializer.SerializeAsync(state.Object, ms, default); // Assert - Assert.Equal(new byte[] { 1, 2, 105, 100, 0, 0, 0, 20, 0, 0, 1, 100, 0 }, ms.ToArray()); + Assert.Equal(AddFlags([2, 105, 100, 0, 0, 0, 20, 0, 0], trackChanges), ms.ToArray()); } [InlineData(FlagOptions.None)] @@ -250,7 +270,7 @@ public async Task SerializeTimeout() public async Task DeserializeTimeout(FlagOptions options) { // Arrange - var data = AddFlags([1, 2, 105, 100, 0, 0, 0, 20, 0, 0], options); + var data = AddFlags([2, 105, 100, 0, 0, 0, 20, 0, 0], options); using var ms = new MemoryStream(data); var serializer = CreateSerializer(); @@ -277,8 +297,10 @@ public async Task DeserializeTimeout(FlagOptions options) } } - [Fact] - public async Task Serialize1Key() + [InlineData(true)] + [InlineData(false)] + [Theory] + public async Task Serialize1Key(bool trackChanges) { // Arrange var obj = new object(); @@ -292,18 +314,20 @@ public async Task Serialize1Key() var bytes = new byte[] { 42 }; keySerializer.Setup(k => k.TrySerialize("key1", obj, out bytes)).Returns(true); - var serializer = CreateSerializer(keySerializer.Object); + var serializer = CreateSerializer(keySerializer.Object, trackChanges); using var ms = new MemoryStream(); // Act await serializer.SerializeAsync(state.Object, 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, 1, 100, 0 }); + Assert.Equal(ms.ToArray(), AddFlags([2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 42, 0], trackChanges)); } - [Fact] - public async Task Serialize1KeyNull() + [InlineData(true)] + [InlineData(false)] + [Theory] + public async Task Serialize1KeyNull(bool trackChanges) { // Arrange var obj = default(object); @@ -317,14 +341,14 @@ public async Task Serialize1KeyNull() var bytes = new byte[] { 0 }; keySerializer.Setup(k => k.TrySerialize("key1", obj, out bytes)).Returns(true); - var serializer = CreateSerializer(keySerializer.Object); + var serializer = CreateSerializer(keySerializer.Object, trackChanges); using var ms = new MemoryStream(); // Act await serializer.SerializeAsync(state.Object, 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, 1, 100, 0 }); + Assert.Equal(ms.ToArray(), AddFlags([2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 0, 0], trackChanges)); } [InlineData(FlagOptions.None)] @@ -334,7 +358,7 @@ public async Task Serialize1KeyNull() public async Task Deserialize1KeyNull(FlagOptions options) { // Arrange - var data = AddFlags([1, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 0, 0], options); + var data = AddFlags([2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 0, 0], options); var obj = new object(); var value = new byte[] { 0 }; @@ -377,7 +401,7 @@ public async Task Deserialize1KeyV1(FlagOptions options) var keySerializer = new Mock(); keySerializer.Setup(k => k.TryDeserialize("key1", Array.Empty(), out obj)).Returns(true); - var data = AddFlags([1, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 0, 0], options); + var data = AddFlags([2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 0, 0], options); using var ms = new MemoryStream(data); var serializer = CreateSerializer(keySerializer.Object); @@ -405,8 +429,10 @@ public async Task Deserialize1KeyV1(FlagOptions options) } } - [Fact] - public async Task Serialize1KeyNullable() + [InlineData(true)] + [InlineData(false)] + [Theory] + public async Task Serialize1KeyNullable(bool trackChanges) { // Arrange var obj = (int?)5; @@ -420,14 +446,14 @@ public async Task Serialize1KeyNullable() var bytes = new byte[] { 0 }; keySerializer.Setup(k => k.TrySerialize("key1", obj, out bytes)).Returns(true); - var serializer = CreateSerializer(keySerializer.Object); + var serializer = CreateSerializer(keySerializer.Object, trackChanges); using var ms = new MemoryStream(); // Act await serializer.SerializeAsync(state.Object, 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, 1, 100, 0 }); + Assert.Equal(ms.ToArray(), AddFlags([2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 0, 0], trackChanges)); } [InlineData(FlagOptions.None)] @@ -442,7 +468,7 @@ public async Task Deserialize1Key(FlagOptions options) var keySerializer = new Mock(); keySerializer.Setup(k => k.TryDeserialize("key1", bytes, out obj)).Returns(true); - var data = AddFlags([1, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 42, 0], options); + var data = AddFlags([2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 42, 0], options); using var ms = new MemoryStream(data); var serializer = CreateSerializer(keySerializer.Object); @@ -526,7 +552,18 @@ private static void RegisterKey(Mock keySerializer, strin keySerializer.Setup(k => k.TrySerialize(name, obj, out data)).Returns(true); } - private static BinarySessionSerializer CreateSerializer(ISessionKeySerializer? keySerializer = null, Action? optionsConfigure = null) + private static BinarySessionSerializer CreateSerializer() => CreateSerializer(null); + + private static BinarySessionSerializer CreateSerializer(bool trackChanges) + => CreateSerializer(null, trackChanges); + + private static BinarySessionSerializer CreateSerializer(ISessionKeySerializer? keySerializer) + => CreateSerializer(keySerializer, _ => { }); + + private static BinarySessionSerializer CreateSerializer(ISessionKeySerializer? keySerializer, bool trackChanges) + => CreateSerializer(keySerializer, options => options.EnableChangeTracking = trackChanges); + + private static BinarySessionSerializer CreateSerializer(ISessionKeySerializer? keySerializer, Action optionsConfigure) { keySerializer ??= new Mock().Object; var logger = new Mock>(); @@ -546,11 +583,14 @@ public enum FlagOptions Changes = 2, } + private static byte[] AddFlags(byte[] data, bool trackChanges) + => AddFlags(data, trackChanges ? FlagOptions.Changes : FlagOptions.None); + private static byte[] AddFlags(byte[] data, FlagOptions options) => options switch { - FlagOptions.None => data, - FlagOptions.Changes => [.. data, 1, 100, 0], - FlagOptions.NoChanges => [.. data, 0], + FlagOptions.None => [ModeStateV1, .. data], + FlagOptions.Changes => [ModeStateV2, .. data, 1, 100, 0], + FlagOptions.NoChanges => [ModeStateV2, .. data, 0], _ => throw new ArgumentOutOfRangeException(nameof(options)), }; 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 0000000000..f0db9c40ef --- /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 853a3e882a..1b64f15747 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 306a2cd286..ef44f97f58 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/RemoteSessionModuleTests.cs b/test/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices.Tests/RemoteSession/RemoteSessionModuleTests.cs index e020e5463a..098f21820b 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 { From 4260e38092f312afedb2ef11bd3592b4fe481350 Mon Sep 17 00:00:00 2001 From: Taylor Southwick Date: Wed, 8 Jan 2025 17:46:11 -0800 Subject: [PATCH 04/10] Rework session serialization V2 due to HTTP2 full duplex mode --- designs/session-serialization.md | 51 ++++++--- .../RemoteSessionCore/appsettings.json | 4 +- .../Serialization/SessionSerializerOptions.cs | 2 +- ...DoubleConnectionRemoteAppSessionManager.cs | 31 ++---- .../RemoteAppSessionDispatcher.cs | 4 +- .../RemoteAppSessionStateManager.cs | 38 ++++++- ...onWriteableRemoteAppSessionStateManager.cs | 27 ++--- .../GetWriteableSessionHandler.cs | 16 +-- .../RemoteSession/ReadOnlySessionHandler.cs | 12 +-- .../RemoteSession/ReadWriteSessionHandler.cs | 23 ++-- .../RemoteSession/RemoteSessionModule.cs | 3 +- .../RemoteSession/VersionedSessionHandler.cs | 28 +++++ ...BinarySessionSerializer.ChangesetWriter.cs | 101 ++++++++++++------ .../BinarySessionSerializer.StateWriter.cs | 20 +--- .../SessionState/BinarySessionSerializer.cs | 50 +++++---- .../BinaryWriterReaderExtensions.cs | 36 ------- .../SessionState/ISessionSerializer.cs | 28 ++++- src/Services/SessionState/SessionConstants.cs | 1 + .../BinarySessionSerializerTests.cs | 3 +- 19 files changed, 269 insertions(+), 209 deletions(-) create mode 100644 src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/VersionedSessionHandler.cs diff --git a/designs/session-serialization.md b/designs/session-serialization.md index ee7cfaec9e..ed84e7879a 100644 --- a/designs/session-serialization.md +++ b/designs/session-serialization.md @@ -2,6 +2,9 @@ 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 @@ -11,21 +14,21 @@ packet-beta 11: "N" 12: "A" 13: "R" -14: "T" -15: "C" -16-24: "Key 1 Blob" -25-33: "Key 2 Blob" -34-42: "..." -43-50: "Flags (variable)" +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 -- *N*: New session -- *A*: Abandoned -- *R*: Readonly -- *T*: Timeout -- *C*: Key count +- `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 @@ -46,9 +49,10 @@ packet-beta ``` Where: -- *Fn*: Flag `n` - -Where `C` is the count of flags, and each `Fn` is a flag identifier an int with 7bit encoding. Each f +- `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: @@ -59,6 +63,23 @@ packet-beta 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: diff --git a/samples/RemoteSession/RemoteSessionCore/appsettings.json b/samples/RemoteSession/RemoteSessionCore/appsettings.json index 406d5fe1df..cef53ea905 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 77c4b166fc..a4d8f8fb3c 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/SessionSerializerOptions.cs +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/SessionSerializerOptions.cs @@ -13,5 +13,5 @@ public class SessionSerializerOptions /// /// Gets or sets whether changes to session state are supported. This requires both the server and clients to be on v2.0+ of the adapters. /// - public bool EnableChangeTracking { get; set; } = true; + public byte DefaultSerializationVersion { get; set; } = 1; } diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/DoubleConnectionRemoteAppSessionManager.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/DoubleConnectionRemoteAppSessionManager.cs index 5fd8957519..93d64914dc 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,31 +71,15 @@ 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) : RemoteSessionHttpContent { - private readonly ISessionSerializer _serializer; - private readonly ISessionState _state; - - public SerializedSessionHttpContent(ISessionSerializer serializer, ISessionState state) - { - _serializer = serializer; - _state = state; - } - - protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) - => SerializeToStreamAsync(stream, context, default); - - protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken) - => _serializer.SerializeAsync(_state, stream, cancellationToken); - - protected override bool TryComputeLength(out long length) + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? _, CancellationToken cancellationToken) { - length = 0; - return false; + return serializer.SerializeAsync(state, context, stream, cancellationToken); } } - private sealed class RemoteSessionState(ISessionState other, HttpRequestMessage request, HttpResponseMessage response, DoubleConnectionRemoteAppSessionManager manager) : DelegatingSessionState + private sealed class RemoteSessionState(ISessionState other, HttpRequestMessage request, HttpResponseMessage responseMessage, DoubleConnectionRemoteAppSessionManager manager) : DelegatingSessionState { protected override ISessionState State => other; @@ -106,15 +90,16 @@ protected override void Dispose(bool disposing) if (disposing) { request.Dispose(); - response.Dispose(); + responseMessage.Dispose(); } } public override async Task CommitAsync(CancellationToken token) { + var sessionContext = manager.GetSupportedSerializerContext(responseMessage); 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); diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/RemoteAppSessionDispatcher.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/RemoteAppSessionDispatcher.cs index 448a81a8b6..ffb1efe13b 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 a5fe6f8d33..76568ba1c8 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,36 @@ 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; + } + + protected abstract class RemoteSessionHttpContent : HttpContent + { + protected sealed override bool TryComputeLength(out long length) + { + length = 0; + return false; + } + + protected sealed override Task SerializeToStreamAsync(Stream stream, TransportContext? context) + => SerializeToStreamAsync(stream, context, default); + } } diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/SingleConnectionWriteableRemoteAppSessionStateManager.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/SingleConnectionWriteableRemoteAppSessionStateManager.cs index 58538cc2f1..1f772991b2 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))] @@ -87,9 +87,9 @@ private sealed partial class SessionPostResultContext : JsonSerializerContext { } - private sealed class CommittingSessionHttpContent : HttpContent + private sealed class CommittingSessionHttpContent : RemoteSessionHttpContent { - private readonly TaskCompletionSource _state; + private readonly TaskCompletionSource<(ISessionState, SessionSerializerContext)> _state; public CommittingSessionHttpContent(ISessionSerializer serializer) { @@ -99,26 +99,19 @@ public CommittingSessionHttpContent(ISessionSerializer serializer) public ISessionSerializer Serializer { get; } - public void Commit(ISessionState state) => _state.SetResult(state); - - protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) - => SerializeToStreamAsync(stream, context, default); + public void Commit(SessionSerializerContext context, ISessionState state) => _state.SetResult((state, context)); 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; - protected override bool TryComputeLength(out long length) - { - length = 0; - return false; + await Serializer.SerializeAsync(state, sessionContext, stream, cancellationToken); } } - 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 +130,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 a3308ae31c..bcf63d1688 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/ReadOnlySessionHandler.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/ReadOnlySessionHandler.cs index e06e597555..9d14ea14ee 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 2d37da96df..658554ab7a 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/ReadWriteSessionHandler.cs +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/ReadWriteSessionHandler.cs @@ -10,7 +10,7 @@ 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; @@ -21,28 +21,21 @@ public ReadWriteSessionHandler(ISessionSerializer serializer, ILogger logger) _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"; @@ -50,7 +43,7 @@ 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); diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/RemoteSessionModule.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/RemoteSessionModule.cs index e124e0f096..aecb11f99d 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/RemoteSessionModule.cs +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices/SessionState/RemoteSession/RemoteSessionModule.cs @@ -28,8 +28,9 @@ public RemoteSessionModule(IOptions sessionO 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 0000000000..cacb2b6d25 --- /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/Services/SessionState/BinarySessionSerializer.ChangesetWriter.cs b/src/Services/SessionState/BinarySessionSerializer.ChangesetWriter.cs index 7eb85837d0..a3da033fab 100644 --- a/src/Services/SessionState/BinarySessionSerializer.ChangesetWriter.cs +++ b/src/Services/SessionState/BinarySessionSerializer.ChangesetWriter.cs @@ -18,9 +18,48 @@ internal partial class BinarySessionSerializer : ISessionSerializer { private readonly struct ChangesetWriter(ISessionKeySerializer serializer) { + private static class PayloadKind + { + // V2 + public const byte Value = 1; + public const byte Removed = 2; + public const byte SupportedVersion = 0XFE; + + // Used to mark the end of the payload + public const byte EndSentinel = 0xFF; + } + + public void Write(ISessionState state, BinaryWriter writer) + { + writer.Write(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.SupportedVersion); + writer.Write(Version2); + + writer.Write(PayloadKind.EndSentinel); + } + public void Write(ISessionStateChangeset state, BinaryWriter writer) { - writer.Write(ModeDelta); + writer.Write(Version2); writer.Write(state.SessionID); writer.Write(state.IsNewSession); @@ -28,30 +67,31 @@ public void Write(ISessionStateChangeset state, BinaryWriter writer) writer.Write(state.IsReadOnly); writer.Write7BitEncodedInt(state.Timeout); - writer.Write7BitEncodedInt(state.Count); foreach (var item in state.Changes) { - writer.Write(item.Key); - - // New with V2 serializer - if (item.State is SessionItemChangeState.NoChange or SessionItemChangeState.Removed) + if (item.State is SessionItemChangeState.NoChange) { - writer.Write7BitEncodedInt((int)item.State); + continue; } - else if (serializer.TrySerialize(item.Key, state[item.Key], out var result)) + else if (item.State is SessionItemChangeState.Removed) { - writer.Write7BitEncodedInt((int)item.State); - writer.Write7BitEncodedInt(result.Length); - writer.Write(result); + writer.Write(PayloadKind.Removed); + writer.Write(item.Key); } - else + else if (item.State is SessionItemChangeState.New or SessionItemChangeState.Changed && serializer.TrySerialize(item.Key, state[item.Key], out var result)) { - writer.Write7BitEncodedInt((int)SessionItemChangeState.Unknown); + writer.Write(PayloadKind.Value); + writer.Write(item.Key); + writer.Write7BitEncodedInt(result.Length); + writer.Write(result); } } - writer.WriteFlags([]); + writer.Write(PayloadKind.SupportedVersion); + writer.Write(Version2); + + writer.Write(PayloadKind.EndSentinel); } public SessionStateCollection Read(BinaryReader reader) @@ -64,27 +104,23 @@ public SessionStateCollection Read(BinaryReader reader) state.IsReadOnly = reader.ReadBoolean(); state.Timeout = reader.Read7BitEncodedInt(); - var count = reader.Read7BitEncodedInt(); - - for (var index = count; index > 0; index--) + while (true) { - var key = reader.ReadString(); - var changeState = (SessionItemChangeState)reader.Read7BitEncodedInt(); + var kind = reader.ReadByte(); - if (changeState is SessionItemChangeState.NoChange) + if (kind == PayloadKind.EndSentinel) { - state.MarkUnchanged(key); + break; } - else if (changeState is SessionItemChangeState.Removed) + + if (kind is PayloadKind.Removed) { + var key = reader.ReadString(); state.MarkRemoved(key); } - else if (changeState is SessionItemChangeState.Unknown) - { - state.SetUnknownKey(key); - } - else if (changeState is SessionItemChangeState.New or SessionItemChangeState.Changed) + else if (kind is PayloadKind.Value) { + var key = reader.ReadString(); var length = reader.Read7BitEncodedInt(); var bytes = reader.ReadBytes(length); @@ -97,14 +133,13 @@ public SessionStateCollection Read(BinaryReader reader) } else { - state.SetUnknownKey(key); + throw new InvalidOperationException($"Unknown session serialization kind '{kind}'"); } } - } - - foreach (var (flag, payload) in reader.ReadFlags()) - { - // No flags are currently read + else if (kind is PayloadKind.SupportedVersion) + { + var version = reader.ReadByte(); + } } return state; diff --git a/src/Services/SessionState/BinarySessionSerializer.StateWriter.cs b/src/Services/SessionState/BinarySessionSerializer.StateWriter.cs index 414ff3af0d..b7845b0004 100644 --- a/src/Services/SessionState/BinarySessionSerializer.StateWriter.cs +++ b/src/Services/SessionState/BinarySessionSerializer.StateWriter.cs @@ -18,13 +18,13 @@ 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 readonly struct StateWriter(ISessionKeySerializer serializer, byte mode) + private readonly struct StateWriter(ISessionKeySerializer serializer) { private const int FLAG_DIFF_SUPPORTED = 100; public void Write(ISessionState state, BinaryWriter writer) { - writer.Write(mode); + writer.Write(Version1); writer.Write(state.SessionID); writer.Write(state.IsNewSession); @@ -65,14 +65,6 @@ public void Write(ISessionState state, BinaryWriter writer) writer.Write(key); } } - - if (mode == ModeStateV2) - { - writer.WriteFlags( - [ - (FLAG_DIFF_SUPPORTED, Array.Empty()) - ]); - } } public SessionStateCollection Read(BinaryReader reader) @@ -106,14 +98,6 @@ public SessionStateCollection Read(BinaryReader reader) } } - if (mode == ModeStateV2) - { - foreach (var (flag, payload) in reader.ReadFlags()) - { - HandleFlag(ref state, flag); - } - } - return state; } diff --git a/src/Services/SessionState/BinarySessionSerializer.cs b/src/Services/SessionState/BinarySessionSerializer.cs index 9146b632ef..ebff8b78b9 100644 --- a/src/Services/SessionState/BinarySessionSerializer.cs +++ b/src/Services/SessionState/BinarySessionSerializer.cs @@ -16,31 +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 ModeStateV1 = 1; - private const byte ModeStateV2 = 2; - private const byte ModeDelta = 3; + private const byte Version1 = 1; + private const byte Version2 = 2; private readonly IOptions _options; private readonly ISessionKeySerializer _serializer; private readonly ILogger _logger; + private readonly StateWriter V1Serializer; + private readonly ChangesetWriter V2Serializer; + public BinarySessionSerializer(ICompositeSessionKeySerializer serializer, IOptions options, ILogger logger) { _serializer = serializer; _options = options; _logger = logger; - } - public void Write(ISessionState state, BinaryWriter writer) - { - if (state is ISessionStateChangeset delta) - { - new ChangesetWriter(_serializer).Write(delta, writer); - } - else - { - new StateWriter(_serializer, _options.Value.EnableChangeTracking ? ModeStateV2 : ModeStateV1).Write(state, writer); - } + V1Serializer = new StateWriter(serializer); + V2Serializer = new ChangesetWriter(serializer); } public ISessionState Read(BinaryReader reader) @@ -54,9 +47,8 @@ public ISessionState Read(BinaryReader reader) return version switch { - ModeStateV1 => new StateWriter(_serializer, ModeStateV1).Read(reader), - ModeStateV2 => new StateWriter(_serializer, ModeStateV2).Read(reader), - ModeDelta => new ChangesetWriter(_serializer).Read(reader), + Version1 => V1Serializer.Read(reader), + Version2 => V2Serializer.Read(reader), _ => throw new InvalidOperationException("Serialized session state has unknown version.") }; } @@ -69,11 +61,31 @@ 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); + var version = context.SupportedVersion == 0 ? Version2 : context.SupportedVersion; + + if (version == 1) + { + V1Serializer.Write(state, writer); + } + else if (version == 2) + { + if (state is ISessionStateChangeset changes) + { + V2Serializer.Write(changes, writer); + } + else + { + V2Serializer.Write(state, writer); + } + } + else + { + throw new InvalidOperationException($"Unsupported serialization version '{version}"); + } return Task.CompletedTask; } diff --git a/src/Services/SessionState/BinaryWriterReaderExtensions.cs b/src/Services/SessionState/BinaryWriterReaderExtensions.cs index abe668cc3f..eea9c941f5 100644 --- a/src/Services/SessionState/BinaryWriterReaderExtensions.cs +++ b/src/Services/SessionState/BinaryWriterReaderExtensions.cs @@ -16,44 +16,8 @@ namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; /// internal static class BinaryWriterReaderExtensions { - internal static void WriteFlags(this BinaryWriter writer, ReadOnlySpan flagEntries) - { - writer.Write7BitEncodedInt(flagEntries.Length); - - foreach (var (flag, payload) in flagEntries) - { - writer.Write7BitEncodedInt(flag); - writer.Write7BitEncodedInt(payload.Length); - writer.Write(payload.Span); - } - } - - internal static IEnumerable ReadFlags(this BinaryReader reader) - { - var length = reader.Read7BitEncodedInt(); - - while (length > 0) - { - var flag = reader.Read7BitEncodedInt(); - var payloadLength = reader.Read7BitEncodedInt(); - var payload = reader.ReadBytes(payloadLength); - - yield return new(flag, payload); - - length--; - } - } #if NETFRAMEWORK - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void Write(this BinaryWriter writer, ReadOnlySpan bytes) - { - var array = ArrayPool.Shared.Rent(bytes.Length); - bytes.CopyTo(array); - writer.Write(array, 0, bytes.Length); - ArrayPool.Shared.Return(array); - } - /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void Write7BitEncodedInt(this BinaryWriter writer, int value) diff --git a/src/Services/SessionState/ISessionSerializer.cs b/src/Services/SessionState/ISessionSerializer.cs index 297c36e73f..1400135df4 100644 --- a/src/Services/SessionState/ISessionSerializer.cs +++ b/src/Services/SessionState/ISessionSerializer.cs @@ -4,6 +4,8 @@ using System.IO; using System.Threading; using System.Threading.Tasks; +using System.Collections.Generic; +using System.Linq; namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; @@ -19,11 +21,33 @@ internal interface ISessionSerializer /// /// Serializes the session state. If the implements it will serialize it - /// in a mode that only tracks the changes that have occurred. + /// in a mode that tracks the changes that have occurred. /// /// /// /// /// - Task SerializeAsync(ISessionState state, Stream stream, CancellationToken token); + Task SerializeAsync(ISessionState state, SessionSerializerContext context, Stream stream, CancellationToken token); +} + +internal sealed class SessionSerializerContext(byte supportedVersion) +{ + public static SessionSerializerContext V1 { get; } = new(1); + + public static SessionSerializerContext V2 { get; } = new(2); + + 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, + }; } diff --git a/src/Services/SessionState/SessionConstants.cs b/src/Services/SessionState/SessionConstants.cs index 219518a9a7..f57283fc04 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/test/Microsoft.AspNetCore.SystemWebAdapters.CoreServices.Tests/SessionState/Serialization/BinarySessionSerializerTests.cs b/test/Microsoft.AspNetCore.SystemWebAdapters.CoreServices.Tests/SessionState/Serialization/BinarySessionSerializerTests.cs index d33ba82e8b..4dedaae5e3 100644 --- a/test/Microsoft.AspNetCore.SystemWebAdapters.CoreServices.Tests/SessionState/Serialization/BinarySessionSerializerTests.cs +++ b/test/Microsoft.AspNetCore.SystemWebAdapters.CoreServices.Tests/SessionState/Serialization/BinarySessionSerializerTests.cs @@ -9,6 +9,7 @@ using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.SystemWebAdapters.SessionState.RemoteSession; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.VisualStudio.TestPlatform.ObjectModel.Client; @@ -497,7 +498,7 @@ public async Task Deserialize1Key(FlagOptions options) } [Fact] - public async Task RoundtripDoesntOverwrite() + public async Task RoundtripDoesNotOverwrite() { // Arrange var obj1 = new object(); From 3fa6fac325e2197ef237b8ef4a4a391ad25ed51c Mon Sep 17 00:00:00 2001 From: Taylor Southwick Date: Fri, 10 Jan 2025 11:35:52 -0800 Subject: [PATCH 05/10] revert a few things --- .../Serialization/SessionSerializerOptions.cs | 5 --- ...DoubleConnectionRemoteAppSessionManager.cs | 32 +++++++++++++++---- .../RemoteAppSessionStateManager.cs | 12 ------- ...onWriteableRemoteAppSessionStateManager.cs | 20 ++++++++++-- 4 files changed, 43 insertions(+), 26 deletions(-) diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/SessionSerializerOptions.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/SessionSerializerOptions.cs index a4d8f8fb3c..61de2b387e 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/SessionSerializerOptions.cs +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/SessionState/Serialization/SessionSerializerOptions.cs @@ -9,9 +9,4 @@ public class SessionSerializerOptions /// Gets or sets whether an exception should be thrown if an unknown session key is encountered. /// public bool ThrowOnUnknownSessionKey { get; set; } = true; - - /// - /// Gets or sets whether changes to session state are supported. This requires both the server and clients to be on v2.0+ of the adapters. - /// - public byte DefaultSerializationVersion { get; set; } = 1; } diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/DoubleConnectionRemoteAppSessionManager.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/DoubleConnectionRemoteAppSessionManager.cs index 93d64914dc..c4b09045ac 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/DoubleConnectionRemoteAppSessionManager.cs +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/DoubleConnectionRemoteAppSessionManager.cs @@ -71,15 +71,33 @@ protected override async Task GetSessionDataAsync(string? session return new RemoteSessionState(remoteSessionState, request, response, this); } - private sealed class SerializedSessionHttpContent(ISessionSerializer serializer, ISessionState state, SessionSerializerContext context) : RemoteSessionHttpContent + private sealed class SerializedSessionHttpContent( + ISessionSerializer serializer, + ISessionState state, + SessionSerializerContext context + ) : HttpContent { + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) + => SerializeToStreamAsync(stream, context, default); + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? _, CancellationToken cancellationToken) { return serializer.SerializeAsync(state, context, stream, cancellationToken); } + + protected override bool TryComputeLength(out long length) + { + length = 0; + return false; + } } - private sealed class RemoteSessionState(ISessionState other, HttpRequestMessage request, HttpResponseMessage responseMessage, DoubleConnectionRemoteAppSessionManager manager) : DelegatingSessionState + private sealed class RemoteSessionState( + ISessionState other, + HttpRequestMessage request, + HttpResponseMessage response, + DoubleConnectionRemoteAppSessionManager manager + ) : DelegatingSessionState { protected override ISessionState State => other; @@ -90,13 +108,13 @@ protected override void Dispose(bool disposing) if (disposing) { request.Dispose(); - responseMessage.Dispose(); + response.Dispose(); } } public override async Task CommitAsync(CancellationToken token) { - var sessionContext = manager.GetSupportedSerializerContext(responseMessage); + var sessionContext = manager.GetSupportedSerializerContext(response); using var request = new HttpRequestMessage(HttpMethod.Put, manager.Options.Path.Relative) { Content = new SerializedSessionHttpContent(manager.Serializer, State, sessionContext) @@ -104,11 +122,11 @@ public override async Task CommitAsync(CancellationToken token) 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/RemoteAppSessionStateManager.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/RemoteAppSessionStateManager.cs index 76568ba1c8..d216038abf 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/RemoteAppSessionStateManager.cs +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/RemoteAppSessionStateManager.cs @@ -121,16 +121,4 @@ protected SessionSerializerContext GetSupportedSerializerContext(HttpResponseMes return context; } - - protected abstract class RemoteSessionHttpContent : HttpContent - { - protected sealed override bool TryComputeLength(out long length) - { - length = 0; - return false; - } - - protected sealed override Task SerializeToStreamAsync(Stream stream, TransportContext? context) - => SerializeToStreamAsync(stream, context, default); - } } diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/SingleConnectionWriteableRemoteAppSessionStateManager.cs b/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/SingleConnectionWriteableRemoteAppSessionStateManager.cs index 1f772991b2..ad5a601635 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/SingleConnectionWriteableRemoteAppSessionStateManager.cs +++ b/src/Microsoft.AspNetCore.SystemWebAdapters.CoreServices/SessionState/RemoteSession/SingleConnectionWriteableRemoteAppSessionStateManager.cs @@ -87,7 +87,7 @@ private sealed partial class SessionPostResultContext : JsonSerializerContext { } - private sealed class CommittingSessionHttpContent : RemoteSessionHttpContent + private sealed class CommittingSessionHttpContent : HttpContent { private readonly TaskCompletionSource<(ISessionState, SessionSerializerContext)> _state; @@ -101,6 +101,9 @@ public CommittingSessionHttpContent(ISessionSerializer serializer) public void Commit(SessionSerializerContext context, ISessionState state) => _state.SetResult((state, context)); + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) + => SerializeToStreamAsync(stream, context, default); + protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken) { await stream.FlushAsync(cancellationToken); @@ -108,10 +111,23 @@ protected override async Task SerializeToStreamAsync(Stream stream, TransportCon await Serializer.SerializeAsync(state, sessionContext, stream, cancellationToken); } + + protected override bool TryComputeLength(out long length) + { + length = 0; + return false; + } } - private sealed class RemoteSessionState(ISessionState other, HttpRequestMessage request, HttpResponseMessage response, SessionSerializerContext sessionContext, 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; From 77b22e71a7ff355af484e1f7b23339b1d276bd05 Mon Sep 17 00:00:00 2001 From: Taylor Southwick Date: Fri, 10 Jan 2025 11:41:12 -0800 Subject: [PATCH 06/10] reorg files --- .../BinarySessionChangesetSerializer.cs | 133 ++++++++++++++++ ...BinarySessionSerializer.ChangesetWriter.cs | 148 ------------------ .../BinarySessionSerializer.StateWriter.cs | 112 ------------- .../SessionState/BinarySessionSerializer.cs | 20 ++- .../BinarySessionStateSerializer.cs | 98 ++++++++++++ .../SessionState/ISessionSerializer.cs | 4 +- 6 files changed, 242 insertions(+), 273 deletions(-) create mode 100644 src/Services/SessionState/BinarySessionChangesetSerializer.cs delete mode 100644 src/Services/SessionState/BinarySessionSerializer.ChangesetWriter.cs delete mode 100644 src/Services/SessionState/BinarySessionSerializer.StateWriter.cs create mode 100644 src/Services/SessionState/BinarySessionStateSerializer.cs diff --git a/src/Services/SessionState/BinarySessionChangesetSerializer.cs b/src/Services/SessionState/BinarySessionChangesetSerializer.cs new file mode 100644 index 0000000000..95c880b1c3 --- /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.ChangesetWriter.cs b/src/Services/SessionState/BinarySessionSerializer.ChangesetWriter.cs deleted file mode 100644 index a3da033fab..0000000000 --- a/src/Services/SessionState/BinarySessionSerializer.ChangesetWriter.cs +++ /dev/null @@ -1,148 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.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; - -[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 readonly struct ChangesetWriter(ISessionKeySerializer serializer) - { - private static class PayloadKind - { - // V2 - public const byte Value = 1; - public const byte Removed = 2; - public const byte SupportedVersion = 0XFE; - - // Used to mark the end of the payload - public const byte EndSentinel = 0xFF; - } - - public void Write(ISessionState state, BinaryWriter writer) - { - writer.Write(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.SupportedVersion); - writer.Write(Version2); - - writer.Write(PayloadKind.EndSentinel); - } - - public void Write(ISessionStateChangeset state, BinaryWriter writer) - { - writer.Write(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.SupportedVersion); - writer.Write(Version2); - - 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}'"); - } - } - else if (kind is PayloadKind.SupportedVersion) - { - var version = reader.ReadByte(); - } - } - - return state; - } - } -} diff --git a/src/Services/SessionState/BinarySessionSerializer.StateWriter.cs b/src/Services/SessionState/BinarySessionSerializer.StateWriter.cs deleted file mode 100644 index b7845b0004..0000000000 --- a/src/Services/SessionState/BinarySessionSerializer.StateWriter.cs +++ /dev/null @@ -1,112 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.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; - -[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 readonly struct StateWriter(ISessionKeySerializer serializer) - { - private const int FLAG_DIFF_SUPPORTED = 100; - - public void Write(ISessionState state, BinaryWriter writer) - { - writer.Write(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; - } - - private static void HandleFlag(ref SessionStateCollection state, int flag) - { - if (flag == FLAG_DIFF_SUPPORTED) - { - state = state.WithTracking(); - } - } - } -} diff --git a/src/Services/SessionState/BinarySessionSerializer.cs b/src/Services/SessionState/BinarySessionSerializer.cs index ebff8b78b9..6a0d27299e 100644 --- a/src/Services/SessionState/BinarySessionSerializer.cs +++ b/src/Services/SessionState/BinarySessionSerializer.cs @@ -16,15 +16,15 @@ 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 Version1 = 1; - private const byte Version2 = 2; + internal const byte Version1 = 1; + internal const byte Version2 = 2; private readonly IOptions _options; private readonly ISessionKeySerializer _serializer; private readonly ILogger _logger; - private readonly StateWriter V1Serializer; - private readonly ChangesetWriter V2Serializer; + private readonly BinarySessionStateSerializer V1Serializer; + private readonly BinarySessionChangesetSerializer V2Serializer; public BinarySessionSerializer(ICompositeSessionKeySerializer serializer, IOptions options, ILogger logger) { @@ -32,8 +32,8 @@ public BinarySessionSerializer(ICompositeSessionKeySerializer serializer, IOptio _options = options; _logger = logger; - V1Serializer = new StateWriter(serializer); - V2Serializer = new ChangesetWriter(serializer); + V1Serializer = new BinarySessionStateSerializer(serializer); + V2Serializer = new BinarySessionChangesetSerializer(serializer); } public ISessionState Read(BinaryReader reader) @@ -65,13 +65,11 @@ public Task SerializeAsync(ISessionState state, SessionSerializerContext context { using var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true); - var version = context.SupportedVersion == 0 ? Version2 : context.SupportedVersion; - - if (version == 1) + if (context.SupportedVersion == 1) { V1Serializer.Write(state, writer); } - else if (version == 2) + else if (context.SupportedVersion == 2) { if (state is ISessionStateChangeset changes) { @@ -84,7 +82,7 @@ public Task SerializeAsync(ISessionState state, SessionSerializerContext context } else { - throw new InvalidOperationException($"Unsupported serialization version '{version}"); + throw new InvalidOperationException($"Unsupported serialization version '{context.SupportedVersion}"); } return Task.CompletedTask; diff --git a/src/Services/SessionState/BinarySessionStateSerializer.cs b/src/Services/SessionState/BinarySessionStateSerializer.cs new file mode 100644 index 0000000000..bad9e43018 --- /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/Services/SessionState/ISessionSerializer.cs b/src/Services/SessionState/ISessionSerializer.cs index 1400135df4..aa953f1e6e 100644 --- a/src/Services/SessionState/ISessionSerializer.cs +++ b/src/Services/SessionState/ISessionSerializer.cs @@ -32,9 +32,9 @@ internal interface ISessionSerializer internal sealed class SessionSerializerContext(byte supportedVersion) { - public static SessionSerializerContext V1 { get; } = new(1); + public static SessionSerializerContext V1 { get; } = new(BinarySessionSerializer.Version1); - public static SessionSerializerContext V2 { get; } = new(2); + public static SessionSerializerContext V2 { get; } = new(BinarySessionSerializer.Version2); public static SessionSerializerContext Latest => V2; From 572302de42c9761bcad7af6cf458e2ca210ecd73 Mon Sep 17 00:00:00 2001 From: Taylor Southwick Date: Fri, 10 Jan 2025 12:01:16 -0800 Subject: [PATCH 07/10] revert old tests --- .../BinarySessionSerializerTests.cs | 389 ++++-------------- .../GetWriteableSessionHandlerTests.cs | 12 +- .../ReadOnlySessionHandlerTests.cs | 6 +- 3 files changed, 82 insertions(+), 325 deletions(-) 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 4dedaae5e3..85f6083e38 100644 --- a/test/Microsoft.AspNetCore.SystemWebAdapters.CoreServices.Tests/SessionState/Serialization/BinarySessionSerializerTests.cs +++ b/test/Microsoft.AspNetCore.SystemWebAdapters.CoreServices.Tests/SessionState/Serialization/BinarySessionSerializerTests.cs @@ -2,17 +2,10 @@ // 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.AspNetCore.SystemWebAdapters.SessionState.RemoteSession; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Microsoft.VisualStudio.TestPlatform.ObjectModel.Client; using Moq; using Xunit; @@ -20,38 +13,31 @@ namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization.Test public class BinarySessionSerializerTests { - private const byte ModeStateV1 = 1; - private const byte ModeStateV2 = 2; - - [InlineData(true)] - [InlineData(false)] - [Theory] - public async Task SerializeEmpty(bool trackChanges) + [Fact] + public async Task SerializeEmpty() { // Arrange - var serializer = CreateSerializer(trackChanges); + var serializer = CreateSerializer(); using var ms = new MemoryStream(); var state = new Mock(); state.Setup(s => s.SessionID).Returns("id"); // Act - await serializer.SerializeAsync(state.Object, ms, default); + await serializer.SerializeAsync(state.Object, SessionSerializerContext.V1, ms, default); // Assert - Assert.Equal(ms.ToArray(), AddFlags([2, 105, 100, 0, 0, 0, 0, 0, 0], trackChanges)); + Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 0, 0 }); } - [InlineData(true)] - [InlineData(false)] - [Theory] - public async Task DeserializeEmpty(bool trackChanges) + [Fact] + public async Task DeserializeEmpty() { // Arrange - var data = AddFlags([2, 105, 100, 0, 0, 0, 0, 0, 0], trackChanges); + var data = new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 0, 0 }; using var ms = new MemoryStream(data); - var serializer = CreateSerializer(trackChanges); + var serializer = CreateSerializer(); // Act var result = await serializer.DeserializeAsync(ms, default); @@ -66,13 +52,11 @@ public async Task DeserializeEmpty(bool trackChanges) Assert.Empty(result.Keys); } - [InlineData(true)] - [InlineData(false)] - [Theory] - public async Task SerializeIsNewSession(bool trackChanges) + [Fact] + public async Task SerializeIsNewSession() { // Arrange - var serializer = CreateSerializer(null, trackChanges); + var serializer = CreateSerializer(); using var ms = new MemoryStream(); var state = new Mock(); @@ -80,19 +64,17 @@ public async Task SerializeIsNewSession(bool trackChanges) state.Setup(s => s.IsNewSession).Returns(true); // Act - await serializer.SerializeAsync(state.Object, ms, default); + await serializer.SerializeAsync(state.Object, SessionSerializerContext.V1, ms, default); // Assert - Assert.Equal(ms.ToArray(), AddFlags([2, 105, 100, 1, 0, 0, 0, 0, 0], trackChanges)); + Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 1, 0, 0, 0, 0, 0 }); } - [InlineData(true)] - [InlineData(false)] - [Theory] - public async Task DeserializeIsNewSession(bool trackChanges) + [Fact] + public async Task DeserializeIsNewSession() { // Arrange - var data = AddFlags([2, 105, 100, 1, 0, 0, 0, 0, 0], trackChanges); + var data = new byte[] { 1, 2, 105, 100, 1, 0, 0, 0, 0, 0 }; using var ms = new MemoryStream(data); var serializer = CreateSerializer(); @@ -110,13 +92,11 @@ public async Task DeserializeIsNewSession(bool trackChanges) Assert.Empty(result.Keys); } - [InlineData(true)] - [InlineData(false)] - [Theory] - public async Task SerializeIsAbandoned(bool trackChanges) + [Fact] + public async Task SerializeIsAbandoned() { // Arrange - var serializer = CreateSerializer(trackChanges); + var serializer = CreateSerializer(); using var ms = new MemoryStream(); var state = new Mock(); @@ -124,19 +104,17 @@ public async Task SerializeIsAbandoned(bool trackChanges) state.Setup(s => s.IsAbandoned).Returns(true); // Act - await serializer.SerializeAsync(state.Object, ms, default); + await serializer.SerializeAsync(state.Object, SessionSerializerContext.V1, ms, default); // Assert - Assert.Equal(ms.ToArray(), AddFlags([2, 105, 100, 0, 1, 0, 0, 0, 0], trackChanges)); + Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 0, 1, 0, 0, 0, 0 }); } - [InlineData(true)] - [InlineData(false)] - [Theory] - public async Task DeserializeIsAbandoned(bool trackChanges) + [Fact] + public async Task DeserializeIsAbandoned() { // Arrange - var data = AddFlags([2, 105, 100, 0, 1, 0, 0, 0, 0], trackChanges); + var data = new byte[] { 1, 2, 105, 100, 0, 1, 0, 0, 0, 0 }; using var ms = new MemoryStream(data); var serializer = CreateSerializer(); @@ -154,13 +132,11 @@ public async Task DeserializeIsAbandoned(bool trackChanges) Assert.Empty(result.Keys); } - [InlineData(true)] - [InlineData(false)] - [Theory] - public async Task SerializeIsReadOnly(bool trackChanges) + [Fact] + public async Task SerializeIsReadOnly() { // Arrange - var serializer = CreateSerializer(trackChanges); + var serializer = CreateSerializer(); using var ms = new MemoryStream(); var state = new Mock(); @@ -168,21 +144,17 @@ public async Task SerializeIsReadOnly(bool trackChanges) state.Setup(s => s.IsReadOnly).Returns(true); // Act - await serializer.SerializeAsync(state.Object, ms, default); + await serializer.SerializeAsync(state.Object, SessionSerializerContext.V1, ms, default); // Assert - Assert.Equal(ms.ToArray(), AddFlags([2, 105, 100, 0, 0, 1, 0, 0, 0], trackChanges)); + Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 0, 0, 1, 0, 0, 0 }); } - [InlineData(FlagOptions.None)] - [InlineData(FlagOptions.Changes)] - [InlineData(FlagOptions.NoChanges)] - [Theory] - public async Task DeserializeIsReadOnly(FlagOptions options) + [Fact] + public async Task DeserializeIsReadOnly() { // Arrange - var data = AddFlags([2, 105, 100, 0, 0, 1, 0, 0, 0], options); - + var data = new byte[] { 1, 2, 105, 100, 0, 0, 1, 0, 0, 0 }; using var ms = new MemoryStream(data); var serializer = CreateSerializer(); @@ -198,26 +170,13 @@ public async Task DeserializeIsReadOnly(FlagOptions options) Assert.Equal(0, result.Timeout); Assert.Equal(0, result.Count); Assert.Empty(result.Keys); - - if (options is FlagOptions.None or FlagOptions.NoChanges) - { - Assert.IsNotAssignableFrom(result); - } - else - { - Assert.IsAssignableFrom(result); - } } - [InlineData(FlagOptions.None)] - [InlineData(FlagOptions.Changes)] - [InlineData(FlagOptions.NoChanges)] - [Theory] - public async Task DeserializeIsReadOnlyEmptyNull(FlagOptions options) + [Fact] + public async Task DeserializeIsReadOnlyEmptyNull() { // Arrange - - var data = AddFlags([2, 105, 100, 0, 0, 1, 0, 0, 0], options); + var data = new byte[] { 1, 2, 105, 100, 0, 0, 1, 0, 0, 0 }; using var ms = new MemoryStream(data); var serializer = CreateSerializer(); @@ -233,24 +192,13 @@ public async Task DeserializeIsReadOnlyEmptyNull(FlagOptions options) Assert.Equal(0, result.Timeout); Assert.Equal(0, result.Count); Assert.Empty(result.Keys); - - if (options is FlagOptions.None or FlagOptions.NoChanges) - { - Assert.IsNotAssignableFrom(result); - } - else - { - Assert.IsAssignableFrom(result); - } } - [InlineData(true)] - [InlineData(false)] - [Theory] - public async Task SerializeTimeout(bool trackChanges) + [Fact] + public async Task SerializeTimeout() { // Arrange - var serializer = CreateSerializer(keySerializer: null, trackChanges: trackChanges); + var serializer = CreateSerializer(); using var ms = new MemoryStream(); var state = new Mock(); @@ -258,20 +206,17 @@ public async Task SerializeTimeout(bool trackChanges) state.Setup(s => s.Timeout).Returns(20); // Act - await serializer.SerializeAsync(state.Object, ms, default); + await serializer.SerializeAsync(state.Object, SessionSerializerContext.V1, ms, default); // Assert - Assert.Equal(AddFlags([2, 105, 100, 0, 0, 0, 20, 0, 0], trackChanges), ms.ToArray()); + Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 0, 0, 0, 20, 0, 0 }); } - [InlineData(FlagOptions.None)] - [InlineData(FlagOptions.Changes)] - [InlineData(FlagOptions.NoChanges)] - [Theory] - public async Task DeserializeTimeout(FlagOptions options) + [Fact] + public async Task DeserializeTimeout() { // Arrange - var data = AddFlags([2, 105, 100, 0, 0, 0, 20, 0, 0], options); + var data = new byte[] { 1, 2, 105, 100, 0, 0, 0, 20, 0, 0 }; using var ms = new MemoryStream(data); var serializer = CreateSerializer(); @@ -287,21 +232,10 @@ public async Task DeserializeTimeout(FlagOptions options) Assert.Equal(20, result.Timeout); Assert.Equal(0, result.Count); Assert.Empty(result.Keys); - - if (options is FlagOptions.None or FlagOptions.NoChanges) - { - Assert.IsNotAssignableFrom(result); - } - else - { - Assert.IsAssignableFrom(result); - } } - [InlineData(true)] - [InlineData(false)] - [Theory] - public async Task Serialize1Key(bool trackChanges) + [Fact] + public async Task Serialize1Key() { // Arrange var obj = new object(); @@ -315,20 +249,18 @@ public async Task Serialize1Key(bool trackChanges) var bytes = new byte[] { 42 }; keySerializer.Setup(k => k.TrySerialize("key1", obj, out bytes)).Returns(true); - var serializer = CreateSerializer(keySerializer.Object, trackChanges); + var serializer = CreateSerializer(keySerializer.Object); using var ms = new MemoryStream(); // Act - await serializer.SerializeAsync(state.Object, ms, default); + await serializer.SerializeAsync(state.Object, SessionSerializerContext.V1, ms, default); // Assert - Assert.Equal(ms.ToArray(), AddFlags([2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 42, 0], trackChanges)); + Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 42, 0 }); } - [InlineData(true)] - [InlineData(false)] - [Theory] - public async Task Serialize1KeyNull(bool trackChanges) + [Fact] + public async Task Serialize1KeyNull() { // Arrange var obj = default(object); @@ -342,24 +274,21 @@ public async Task Serialize1KeyNull(bool trackChanges) var bytes = new byte[] { 0 }; keySerializer.Setup(k => k.TrySerialize("key1", obj, out bytes)).Returns(true); - var serializer = CreateSerializer(keySerializer.Object, trackChanges); + var serializer = CreateSerializer(keySerializer.Object); using var ms = new MemoryStream(); // Act - await serializer.SerializeAsync(state.Object, ms, default); + await serializer.SerializeAsync(state.Object, SessionSerializerContext.V1, ms, default); // Assert - Assert.Equal(ms.ToArray(), AddFlags([2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 0, 0], trackChanges)); + Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 0, 0 }); } - [InlineData(FlagOptions.None)] - [InlineData(FlagOptions.Changes)] - [InlineData(FlagOptions.NoChanges)] - [Theory] - public async Task Deserialize1KeyNull(FlagOptions options) + [Fact] + public async Task Deserialize1KeyNull() { // Arrange - var data = AddFlags([2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 0, 0], options); + 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 }; @@ -380,29 +309,17 @@ public async Task Deserialize1KeyNull(FlagOptions options) Assert.Equal(1, result.Count); Assert.Same(obj, result["key1"]); Assert.Collection(result.Keys, k => Assert.Equal("key1", k)); - - if (options is FlagOptions.None or FlagOptions.NoChanges) - { - Assert.IsNotAssignableFrom(result); - } - else - { - Assert.IsAssignableFrom(result); - } } - [InlineData(FlagOptions.None)] - [InlineData(FlagOptions.Changes)] - [InlineData(FlagOptions.NoChanges)] - [Theory] - public async Task Deserialize1KeyV1(FlagOptions options) + [Fact] + public async Task Deserialize1KeyV1() { // Arrange var obj = new object(); var keySerializer = new Mock(); keySerializer.Setup(k => k.TryDeserialize("key1", Array.Empty(), out obj)).Returns(true); - var data = AddFlags([2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 0, 0], options); + 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); @@ -419,21 +336,10 @@ public async Task Deserialize1KeyV1(FlagOptions options) Assert.Equal(1, result.Count); Assert.Equal(result.Keys, new[] { "key1" }); Assert.Equal(obj, result["key1"]); - - if (options is FlagOptions.None or FlagOptions.NoChanges) - { - Assert.IsNotAssignableFrom(result); - } - else - { - Assert.IsAssignableFrom(result); - } } - [InlineData(true)] - [InlineData(false)] - [Theory] - public async Task Serialize1KeyNullable(bool trackChanges) + [Fact] + public async Task Serialize1KeyNullable() { // Arrange var obj = (int?)5; @@ -447,21 +353,18 @@ public async Task Serialize1KeyNullable(bool trackChanges) var bytes = new byte[] { 0 }; keySerializer.Setup(k => k.TrySerialize("key1", obj, out bytes)).Returns(true); - var serializer = CreateSerializer(keySerializer.Object, trackChanges); + var serializer = CreateSerializer(keySerializer.Object); using var ms = new MemoryStream(); // Act - await serializer.SerializeAsync(state.Object, ms, default); + await serializer.SerializeAsync(state.Object, SessionSerializerContext.V1, ms, default); // Assert - Assert.Equal(ms.ToArray(), AddFlags([2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 0, 0], trackChanges)); + Assert.Equal(ms.ToArray(), new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 0, 0 }); } - [InlineData(FlagOptions.None)] - [InlineData(FlagOptions.Changes)] - [InlineData(FlagOptions.NoChanges)] - [Theory] - public async Task Deserialize1Key(FlagOptions options) + [Fact] + public async Task Deserialize1Key() { // Arrange var obj = new object(); @@ -469,7 +372,7 @@ public async Task Deserialize1Key(FlagOptions options) var keySerializer = new Mock(); keySerializer.Setup(k => k.TryDeserialize("key1", bytes, out obj)).Returns(true); - var data = AddFlags([2, 105, 100, 0, 0, 0, 0, 1, 4, 107, 101, 121, 49, 1, 42, 0], options); + 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); @@ -486,115 +389,19 @@ public async Task Deserialize1Key(FlagOptions options) Assert.Equal(1, result.Count); Assert.Equal(result.Keys, new[] { "key1" }); Assert.Equal(obj, result["key1"]); - - if (options is FlagOptions.None or FlagOptions.NoChanges) - { - Assert.IsNotAssignableFrom(result); - } - else - { - Assert.IsAssignableFrom(result); - } } - [Fact] - public async Task RoundtripDoesNotOverwrite() - { - // Arrange - var obj1 = new object(); - var bytes1 = new byte[] { 42 }; - var obj2 = new object(); - var bytes2 = new byte[] { 43 }; - var keySerializer1 = new Mock(); - RegisterKey(keySerializer1, "key1", obj1, bytes1); - - var serializer1 = CreateSerializer(keySerializer1.Object, options => options.ThrowOnUnknownSessionKey = false); - - using var initialState = new TestState() - { - { "key1", obj1 }, - { "key2", obj2 }, - }; - - // Act - var state2 = await RoundtripAsync(serializer1, initialState); - - // Assert - var changeset = Assert.IsAssignableFrom(state2); - - Assert.Collection( - changeset.Changes, - c => - { - Assert.Equal("key1", c.Key); - Assert.Equal(SessionItemChangeState.NoChange, c.State); - }, - c => - { - Assert.Equal("key2", c.Key); - Assert.Equal(SessionItemChangeState.NoChange, c.State); - }); - } - - private static async Task RoundtripAsync(BinarySessionSerializer serializer, ISessionState state) - { - using var ms = new MemoryStream(); - await serializer.SerializeAsync(state, ms, default); - ms.Position = 0; - - var result = await serializer.DeserializeAsync(ms, default); - - return result!; - } - - private static void RegisterKey(Mock keySerializer, string name, object? obj, byte[] data) - { - keySerializer.Setup(k => k.TryDeserialize(name, data, out obj)).Returns(true); - keySerializer.Setup(k => k.TrySerialize(name, obj, out data)).Returns(true); - } - - private static BinarySessionSerializer CreateSerializer() => CreateSerializer(null); - - private static BinarySessionSerializer CreateSerializer(bool trackChanges) - => CreateSerializer(null, trackChanges); - - private static BinarySessionSerializer CreateSerializer(ISessionKeySerializer? keySerializer) - => CreateSerializer(keySerializer, _ => { }); - - private static BinarySessionSerializer CreateSerializer(ISessionKeySerializer? keySerializer, bool trackChanges) - => CreateSerializer(keySerializer, options => options.EnableChangeTracking = trackChanges); - - private static BinarySessionSerializer CreateSerializer(ISessionKeySerializer? keySerializer, Action optionsConfigure) + private static BinarySessionSerializer CreateSerializer(ISessionKeySerializer? keySerializer = null) { keySerializer ??= new Mock().Object; var logger = new Mock>(); var optionContainer = new Mock>(); - var options = new SessionSerializerOptions(); - optionsConfigure?.Invoke(options); - optionContainer.Setup(o => o.Value).Returns(options); + optionContainer.Setup(o => o.Value).Returns(new SessionSerializerOptions()); return new BinarySessionSerializer(new Composite(keySerializer), optionContainer.Object, logger.Object); } - public enum FlagOptions - { - None = 0, - NoChanges = 1, - Changes = 2, - } - - private static byte[] AddFlags(byte[] data, bool trackChanges) - => AddFlags(data, trackChanges ? FlagOptions.Changes : FlagOptions.None); - - private static byte[] AddFlags(byte[] data, FlagOptions options) => options switch - { - FlagOptions.None => [ModeStateV1, .. data], - FlagOptions.Changes => [ModeStateV2, .. data, 1, 100, 0], - FlagOptions.NoChanges => [ModeStateV2, .. data, 0], - _ => throw new ArgumentOutOfRangeException(nameof(options)), - }; - private sealed class Composite : ICompositeSessionKeySerializer { private readonly ISessionKeySerializer _serializer; @@ -610,60 +417,4 @@ public bool TryDeserialize(string key, byte[] bytes, out object? obj) public bool TrySerialize(string key, object? value, out byte[] bytes) => _serializer.TrySerialize(key, value, out bytes); } - - private sealed class TestState : ISessionState, IEnumerable> - { - private readonly Dictionary _items = []; - - public object? this[string key] - { - get => _items.TryGetValue(key, out var value) ? value : null; - set - { - if (value is null) - { - _items.Remove(key); - } - else - { - _items[key] = value; - } - } - } - - public void Add(string key, object value) - { - _items.Add(key, value); - } - - public string SessionID => "id"; - - public bool IsReadOnly => false; - - public int Timeout { get; set; } - - public bool IsNewSession => true; - - public int Count => _items.Count; - - public bool IsSynchronized => false; - - public object SyncRoot => _items; - - public bool IsAbandoned { get; set; } - - public IEnumerable Keys => _items.Keys; - - public void Clear() => _items.Clear(); - public Task CommitAsync(CancellationToken token) => Task.CompletedTask; - public void Dispose() - { - } - - public void Remove(string key) => _items.Remove(key); - - public IEnumerator> GetEnumerator() => _items.GetEnumerator(); - - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - } } diff --git a/test/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices.Tests/RemoteSession/GetWriteableSessionHandlerTests.cs b/test/Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices.Tests/RemoteSession/GetWriteableSessionHandlerTests.cs index e36111727d..007472de6f 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 4833b86748..e4cfb2f20a 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); } } From eb85a819b6a1cc41df11d1e44bcecfb61d24ada3 Mon Sep 17 00:00:00 2001 From: Taylor Southwick Date: Fri, 10 Jan 2025 14:20:37 -0800 Subject: [PATCH 08/10] start reworking tests --- .../SessionState/ISessionSerializer.cs | 23 +---------- .../SessionState/SessionSerializerContext.cs | 40 +++++++++++++++++++ .../BinarySessionSerializerTests.cs | 34 +++++++++------- 3 files changed, 61 insertions(+), 36 deletions(-) create mode 100644 src/Services/SessionState/SessionSerializerContext.cs diff --git a/src/Services/SessionState/ISessionSerializer.cs b/src/Services/SessionState/ISessionSerializer.cs index aa953f1e6e..aa799f4486 100644 --- a/src/Services/SessionState/ISessionSerializer.cs +++ b/src/Services/SessionState/ISessionSerializer.cs @@ -1,6 +1,7 @@ // 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; @@ -29,25 +30,3 @@ internal interface ISessionSerializer /// Task SerializeAsync(ISessionState state, SessionSerializerContext context, Stream stream, CancellationToken token); } - -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, - }; -} diff --git a/src/Services/SessionState/SessionSerializerContext.cs b/src/Services/SessionState/SessionSerializerContext.cs new file mode 100644 index 0000000000..715beb4808 --- /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/test/Microsoft.AspNetCore.SystemWebAdapters.CoreServices.Tests/SessionState/Serialization/BinarySessionSerializerTests.cs b/test/Microsoft.AspNetCore.SystemWebAdapters.CoreServices.Tests/SessionState/Serialization/BinarySessionSerializerTests.cs index 85f6083e38..e8f559e61f 100644 --- a/test/Microsoft.AspNetCore.SystemWebAdapters.CoreServices.Tests/SessionState/Serialization/BinarySessionSerializerTests.cs +++ b/test/Microsoft.AspNetCore.SystemWebAdapters.CoreServices.Tests/SessionState/Serialization/BinarySessionSerializerTests.cs @@ -13,8 +13,10 @@ namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization.Test 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 +26,18 @@ public async Task SerializeEmpty() state.Setup(s => s.SessionID).Returns("id"); // Act - await serializer.SerializeAsync(state.Object, SessionSerializerContext.V1, 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 +55,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 +69,18 @@ public async Task SerializeIsNewSession() state.Setup(s => s.IsNewSession).Returns(true); // Act - await serializer.SerializeAsync(state.Object, SessionSerializerContext.V1, 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(); From 5ebfb585ceddf2a74675df248bb7793722946b42 Mon Sep 17 00:00:00 2001 From: Taylor Southwick Date: Fri, 10 Jan 2025 14:26:38 -0800 Subject: [PATCH 09/10] revert to extensions --- .../SessionState/BinaryWriterReaderExtensions.cs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/Services/SessionState/BinaryWriterReaderExtensions.cs b/src/Services/SessionState/BinaryWriterReaderExtensions.cs index eea9c941f5..1ee67390b4 100644 --- a/src/Services/SessionState/BinaryWriterReaderExtensions.cs +++ b/src/Services/SessionState/BinaryWriterReaderExtensions.cs @@ -1,14 +1,14 @@ // 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; -using FlagEntry = (int Flag, System.ReadOnlyMemory Payload); - namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; /// @@ -16,8 +16,6 @@ namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization; /// internal static class BinaryWriterReaderExtensions { - -#if NETFRAMEWORK /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void Write7BitEncodedInt(this BinaryWriter writer, int value) @@ -84,6 +82,5 @@ public static int Read7BitEncodedInt(this BinaryReader reader) result |= (uint)byteReadJustNow << (MaxBytesWithoutOverflow * 7); return (int)result; } -#endif } - +#endif From 6af59979ad783596ad6e5479448c392d5dcb9d00 Mon Sep 17 00:00:00 2001 From: Taylor Southwick Date: Mon, 13 Jan 2025 17:55:42 -0800 Subject: [PATCH 10/10] add tests --- .../BinarySessionSerializerTests.cs | 110 +++++++++++------- 1 file changed, 65 insertions(+), 45 deletions(-) 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 e8f559e61f..3ecf7bc4ac 100644 --- a/test/Microsoft.AspNetCore.SystemWebAdapters.CoreServices.Tests/SessionState/Serialization/BinarySessionSerializerTests.cs +++ b/test/Microsoft.AspNetCore.SystemWebAdapters.CoreServices.Tests/SessionState/Serialization/BinarySessionSerializerTests.cs @@ -11,6 +11,7 @@ namespace Microsoft.AspNetCore.SystemWebAdapters.SessionState.Serialization.Tests; +[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1062:Validate arguments of public methods", Justification = "Test arguments")] public class BinarySessionSerializerTests { [InlineData(new byte[] { 1, 2, 105, 100, 0, 0, 0, 0, 0, 0 })] @@ -98,8 +99,10 @@ public async Task DeserializeIsNewSession(byte[] data) 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(); @@ -110,17 +113,18 @@ public async Task SerializeIsAbandoned() state.Setup(s => s.IsAbandoned).Returns(true); // Act - await serializer.SerializeAsync(state.Object, SessionSerializerContext.V1, 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(); @@ -138,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(); @@ -150,17 +156,18 @@ public async Task SerializeIsReadOnly() state.Setup(s => s.IsReadOnly).Returns(true); // Act - await serializer.SerializeAsync(state.Object, SessionSerializerContext.V1, 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(); @@ -178,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(); @@ -200,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(); @@ -212,17 +222,18 @@ public async Task SerializeTimeout() state.Setup(s => s.Timeout).Returns(20); // Act - await serializer.SerializeAsync(state.Object, SessionSerializerContext.V1, 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(); @@ -240,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(); @@ -259,14 +272,16 @@ public async Task Serialize1Key() using var ms = new MemoryStream(); // Act - await serializer.SerializeAsync(state.Object, SessionSerializerContext.V1, 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); @@ -284,17 +299,18 @@ public async Task Serialize1KeyNull() using var ms = new MemoryStream(); // Act - await serializer.SerializeAsync(state.Object, SessionSerializerContext.V1, 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 }; @@ -317,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); @@ -344,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; @@ -363,14 +382,16 @@ public async Task Serialize1KeyNullable() using var ms = new MemoryStream(); // Act - await serializer.SerializeAsync(state.Object, SessionSerializerContext.V1, 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(); @@ -378,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);