From 2f9a24f3c79873fe96709db3894785a7d22d2065 Mon Sep 17 00:00:00 2001 From: Daria Tiurina Date: Tue, 7 Jul 2026 18:13:50 +0200 Subject: [PATCH 1/4] Unify behaviour --- .../src/SessionCascadingValueSupplier.cs | 12 +++++-- .../Endpoints/src/SessionResolver.cs | 27 +++++++++++++++ .../src/TempData/CookieTempDataProvider.cs | 25 ++++++++------ .../SessionStorageTempDataProvider.cs | 6 ++-- .../TempDataCascadingValueSupplier.cs | 4 +++ .../SessionCascadingValueSupplierTest.cs | 34 +++++++++++++++++-- .../test/Session/SessionSubscriptionTest.cs | 31 +++++++++++++++++ .../SessionStorageTempDataProviderTest.cs | 25 +++++++++++--- .../test/TempData/TempDataSubscriptionTest.cs | 19 +++++++++++ 9 files changed, 161 insertions(+), 22 deletions(-) create mode 100644 src/Components/Endpoints/src/SessionResolver.cs diff --git a/src/Components/Endpoints/src/SessionCascadingValueSupplier.cs b/src/Components/Endpoints/src/SessionCascadingValueSupplier.cs index 33ce74b0fc36..57e3b9fcce98 100644 --- a/src/Components/Endpoints/src/SessionCascadingValueSupplier.cs +++ b/src/Components/Endpoints/src/SessionCascadingValueSupplier.cs @@ -8,7 +8,6 @@ using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Components.Endpoints; @@ -61,7 +60,11 @@ private static PropertyGetter PropertyGetterFactory((Type type, string propertyN return new PropertyGetter(type, propertyInfo); } - internal ISession? GetSession() => _httpContext?.Features.Get()?.Session; + // A null HttpContext means we're rendering interactively (Server circuit or WebAssembly), + // where the session isn't available; yield null instead of failing. When an HttpContext is + // present (static SSR) an unavailable session is a misconfiguration and fails fast. + internal ISession? GetSession() + => _httpContext is null ? null : SessionResolver.GetRequiredSession(_httpContext); internal void RegisterValueCallback(string sessionKey, Func valueGetter) { @@ -81,6 +84,7 @@ internal Task PersistAllValues() var session = GetSession(); if (session is null) { + Log.SessionUnavailable(_logger); return Task.CompletedTask; } @@ -120,6 +124,9 @@ private static partial class Log [LoggerMessage(2, LogLevel.Warning, "Deserialization of the element from session failed.", EventName = "SessionDeserializeFail")] public static partial void SessionDeserializeFail(ILogger logger, Exception exception); + + [LoggerMessage(3, LogLevel.Warning, "No active HttpContext is available (interactive rendering); [SupplyParameterFromSession] is skipped.", EventName = "SessionUnavailable")] + public static partial void SessionUnavailable(ILogger logger); } internal partial class SessionSubscription : CascadingParameterSubscription @@ -153,6 +160,7 @@ public SessionSubscription( var session = _owner.GetSession(); if (session is null) { + Log.SessionUnavailable(_owner._logger); return null; } diff --git a/src/Components/Endpoints/src/SessionResolver.cs b/src/Components/Endpoints/src/SessionResolver.cs new file mode 100644 index 000000000000..0c125ad25eac --- /dev/null +++ b/src/Components/Endpoints/src/SessionResolver.cs @@ -0,0 +1,27 @@ +// 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.Http; + +namespace Microsoft.AspNetCore.Components.Endpoints; + +internal static class SessionResolver +{ + // Matches the message thrown by HttpContext.Session so both session-consuming + // features surface the same InvalidOperationException when the session is unavailable. + internal const string SessionNotConfiguredMessage = "Session has not been configured for this application or request."; + + // Fails fast the first time the session is touched and found unavailable, whether + // session middleware isn't configured or the feature exposes a null session. + // Callers that legitimately have no HttpContext (interactive rendering) must not call this. + internal static ISession GetRequiredSession(HttpContext httpContext) + { + var session = httpContext.Session; + if (session is null) + { + throw new InvalidOperationException(SessionNotConfiguredMessage); + } + + return session; + } +} diff --git a/src/Components/Endpoints/src/TempData/CookieTempDataProvider.cs b/src/Components/Endpoints/src/TempData/CookieTempDataProvider.cs index 05c95a6b4ff0..3bd516c304ba 100644 --- a/src/Components/Endpoints/src/TempData/CookieTempDataProvider.cs +++ b/src/Components/Endpoints/src/TempData/CookieTempDataProvider.cs @@ -43,19 +43,22 @@ public CookieTempDataProvider( ArgumentNullException.ThrowIfNull(context); var cookieName = _options.TempDataCookie.Name ?? CookieName; - try + if (!context.Request.Cookies.ContainsKey(cookieName)) { - if (!context.Request.Cookies.ContainsKey(cookieName)) - { - Log.TempDataCookieNotFound(_logger, cookieName); - return ReadOnlyDictionary.Empty; - } - var serializedDataFromCookie = _chunkingCookieManager.GetRequestCookie(context, cookieName); - if (serializedDataFromCookie is null) - { - return ReadOnlyDictionary.Empty; - } + Log.TempDataCookieNotFound(_logger, cookieName); + return ReadOnlyDictionary.Empty; + } + var serializedDataFromCookie = _chunkingCookieManager.GetRequestCookie(context, cookieName); + if (serializedDataFromCookie is null) + { + return ReadOnlyDictionary.Empty; + } + + // Only the decode/unprotect/deserialize of untrusted cookie data is guarded: data errors + // fall back to empty, while unexpected errors surface instead of being silently swallowed. + try + { byte[]? rentedDecodeBuffer = null; var maxDecodedSize = Base64Url.GetMaxDecodedLength(serializedDataFromCookie.Length); var decodeBuffer = maxDecodedSize <= 256 diff --git a/src/Components/Endpoints/src/TempData/SessionStorageTempDataProvider.cs b/src/Components/Endpoints/src/TempData/SessionStorageTempDataProvider.cs index a0f81222d83d..677aed0bcc5d 100644 --- a/src/Components/Endpoints/src/TempData/SessionStorageTempDataProvider.cs +++ b/src/Components/Endpoints/src/TempData/SessionStorageTempDataProvider.cs @@ -25,10 +25,10 @@ public SessionStorageTempDataProvider( { ArgumentNullException.ThrowIfNull(context); + var session = SessionResolver.GetRequiredSession(context); + try { - var session = context.Session; - if (session.TryGetValue(TempDataSessionStateKey, out var value)) { var dataFromSession = JsonSerializer.Deserialize>(value); @@ -63,7 +63,7 @@ public void SaveTempData(HttpContext context, IDictionary value } } - var session = context.Session; + var session = SessionResolver.GetRequiredSession(context); if (values.Count == 0) { session.Remove(TempDataSessionStateKey); diff --git a/src/Components/Endpoints/src/TempData/TempDataCascadingValueSupplier.cs b/src/Components/Endpoints/src/TempData/TempDataCascadingValueSupplier.cs index f2089a638f77..d3f66faaab83 100644 --- a/src/Components/Endpoints/src/TempData/TempDataCascadingValueSupplier.cs +++ b/src/Components/Endpoints/src/TempData/TempDataCascadingValueSupplier.cs @@ -99,6 +99,9 @@ private static partial class Log [LoggerMessage(2, LogLevel.Warning, "Deserialization of the element from TempData failed.", EventName = "TempDataDeserializeFail")] public static partial void TempDataDeserializeFail(ILogger logger, Exception exception); + + [LoggerMessage(3, LogLevel.Warning, "No active HttpContext is available (interactive rendering); the [SupplyParameterFromTempData] value is skipped and will be null.", EventName = "TempDataUnavailableForRead")] + public static partial void TempDataUnavailableForRead(ILogger logger); } internal partial class TempDataSubscription : CascadingParameterSubscription @@ -137,6 +140,7 @@ public TempDataSubscription( var tempData = _owner.GetTempData(); if (tempData is null) { + Log.TempDataUnavailableForRead(_owner._logger); return null; } diff --git a/src/Components/Endpoints/test/Session/SessionCascadingValueSupplierTest.cs b/src/Components/Endpoints/test/Session/SessionCascadingValueSupplierTest.cs index 7c4b72210459..7e3145e58e5e 100644 --- a/src/Components/Endpoints/test/Session/SessionCascadingValueSupplierTest.cs +++ b/src/Components/Endpoints/test/Session/SessionCascadingValueSupplierTest.cs @@ -5,7 +5,9 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Testing; namespace Microsoft.AspNetCore.Components.Endpoints; @@ -140,7 +142,23 @@ public async Task PersistAllValues_LowercasesSessionKey() } [Fact] - public async Task PersistAllValues_NoOp_WhenSessionUnavailable() + public async Task PersistAllValues_LogsWarningAndSkips_WhenHttpContextNotSet() + { + var sink = new TestSink(); + var supplier = new SessionCascadingValueSupplier( + new TestLoggerFactory(sink, enabled: true).CreateLogger()); + supplier.RegisterValueCallback("key", () => "value"); + + // No SetRequestContext: mimics interactive rendering where no HttpContext is available. + await supplier.PersistAllValues(); + + var write = Assert.Single(sink.Writes); + Assert.Equal(LogLevel.Warning, write.LogLevel); + Assert.Equal("SessionUnavailable", write.EventId.Name); + } + + [Fact] + public async Task PersistAllValues_Throws_WhenSessionUnavailable() { _supplier.RegisterValueCallback("key", () => "value"); @@ -148,7 +166,19 @@ public async Task PersistAllValues_NoOp_WhenSessionUnavailable() httpContext.Features.Set(new TestHttpResponseFeature()); _supplier.SetRequestContext(httpContext); - await _supplier.PersistAllValues(); + await Assert.ThrowsAsync(() => _supplier.PersistAllValues()); + } + + [Fact] + public async Task PersistAllValues_Throws_WhenSessionIsNull() + { + _supplier.RegisterValueCallback("key", () => "value"); + + var httpContext = new DefaultHttpContext(); + httpContext.Features.Set(new TestSessionFeature(null!)); + _supplier.SetRequestContext(httpContext); + + await Assert.ThrowsAsync(() => _supplier.PersistAllValues()); } [Fact] diff --git a/src/Components/Endpoints/test/Session/SessionSubscriptionTest.cs b/src/Components/Endpoints/test/Session/SessionSubscriptionTest.cs index 976afc7a3993..89c83430aca5 100644 --- a/src/Components/Endpoints/test/Session/SessionSubscriptionTest.cs +++ b/src/Components/Endpoints/test/Session/SessionSubscriptionTest.cs @@ -8,7 +8,9 @@ using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Testing; using static Microsoft.AspNetCore.Components.Endpoints.SessionCascadingValueSupplierTest; namespace Microsoft.AspNetCore.Components.Endpoints; @@ -43,6 +45,35 @@ public void GetValue_ReturnsNull_WhenHttpContextNotSet() Assert.Null(result); } + [Fact] + public void GetValue_LogsWarning_WhenHttpContextNotSet() + { + var sink = new TestSink(); + var supplier = new SessionCascadingValueSupplier( + new TestLoggerFactory(sink, enabled: true).CreateLogger()); + var subscription = new SessionCascadingValueSupplier.SessionSubscription( + supplier, "key", typeof(string), () => _component.Value); + + var result = subscription.GetCurrentValue(); + + Assert.Null(result); + var write = Assert.Single(sink.Writes); + Assert.Equal(LogLevel.Warning, write.LogLevel); + Assert.Equal("SessionUnavailable", write.EventId.Name); + } + + [Fact] + public void GetValue_Throws_WhenSessionIsNull() + { + var httpContext = new DefaultHttpContext(); + httpContext.Features.Set(new TestSessionFeature(null!)); + _supplier.SetRequestContext(httpContext); + + var subscription = CreateSubscription("key", typeof(string)); + + Assert.Throws(() => subscription.GetCurrentValue()); + } + [Fact] public void GetValue_ReturnsNull_WhenKeyNotFound() { diff --git a/src/Components/Endpoints/test/TempData/SessionStorageTempDataProviderTest.cs b/src/Components/Endpoints/test/TempData/SessionStorageTempDataProviderTest.cs index 4b0c610ae99e..520ec9b4a85f 100644 --- a/src/Components/Endpoints/test/TempData/SessionStorageTempDataProviderTest.cs +++ b/src/Components/Endpoints/test/TempData/SessionStorageTempDataProviderTest.cs @@ -71,13 +71,30 @@ public void Save_ThrowsForUnsupportedType() } [Fact] - public void Load_ReturnsEmptyTempData_WhenSessionThrows() + public void Load_Throws_WhenSessionNotConfigured() { var httpContext = CreateHttpContext(throwOnSessionAccess: true); - var tempData = _sessionStateTempDataProvider.LoadTempData(httpContext); - Assert.NotNull(tempData); - Assert.Empty(tempData); + Assert.Throws(() => _sessionStateTempDataProvider.LoadTempData(httpContext)); + } + + [Fact] + public void Load_Throws_WhenSessionIsNull() + { + var httpContext = new DefaultHttpContext(); + httpContext.Features.Set(new TestSessionFeature { Session = null }); + + Assert.Throws(() => _sessionStateTempDataProvider.LoadTempData(httpContext)); + } + + [Fact] + public void Save_Throws_WhenSessionNotConfigured() + { + var httpContext = CreateHttpContext(throwOnSessionAccess: true); + var tempData = CreateTempData(); + tempData["Key1"] = "Value1"; + + Assert.Throws(() => _sessionStateTempDataProvider.SaveTempData(httpContext, tempData.Save())); } [Fact] diff --git a/src/Components/Endpoints/test/TempData/TempDataSubscriptionTest.cs b/src/Components/Endpoints/test/TempData/TempDataSubscriptionTest.cs index cf91bb6ad405..2b55d155488b 100644 --- a/src/Components/Endpoints/test/TempData/TempDataSubscriptionTest.cs +++ b/src/Components/Endpoints/test/TempData/TempDataSubscriptionTest.cs @@ -5,7 +5,9 @@ using Microsoft.AspNetCore.Components.Test.Helpers; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Testing; using Moq; namespace Microsoft.AspNetCore.Components.Endpoints; @@ -47,6 +49,23 @@ public void GetValue_ReturnsNull_WhenHttpContextNotSet() Assert.Null(result); } + [Fact] + public void GetValue_LogsWarning_WhenHttpContextNotSet() + { + var sink = new TestSink(); + var supplier = new TempDataCascadingValueSupplier( + new TestLoggerFactory(sink, enabled: true).CreateLogger()); + var subscription = new TempDataCascadingValueSupplier.TempDataSubscription( + supplier, "key", typeof(string), () => _component.Value); + + var result = subscription.GetCurrentValue(); + + Assert.Null(result); + var write = Assert.Single(sink.Writes); + Assert.Equal(LogLevel.Warning, write.LogLevel); + Assert.Equal("TempDataUnavailableForRead", write.EventId.Name); + } + [Fact] public void GetValue_ReturnsNull_WhenKeyNotFound() { From db158ded3b1c2b85a966cc720b8fbf9911fda099 Mon Sep 17 00:00:00 2001 From: Daria Tiurina Date: Wed, 8 Jul 2026 10:48:10 +0200 Subject: [PATCH 2/4] Fix tests --- .../test/E2ETest/Tests/TempDataCookieTest.cs | 2 +- .../StreamingCookieTempDataPersistence.razor | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/StreamingCookieTempDataPersistence.razor diff --git a/src/Components/test/E2ETest/Tests/TempDataCookieTest.cs b/src/Components/test/E2ETest/Tests/TempDataCookieTest.cs index 19bde0ee6c09..69e0e6c7180f 100644 --- a/src/Components/test/E2ETest/Tests/TempDataCookieTest.cs +++ b/src/Components/test/E2ETest/Tests/TempDataCookieTest.cs @@ -160,7 +160,7 @@ public void SupplyParameterFromTempDataReadsAndSavesValues() [Fact] public void StreamingSSR_CookieTempData_DoesNotPersistValuesWrittenAfterFirstFlush() { - Navigate($"{ServerPathBase}/streaming-session-persistence"); + Navigate($"{ServerPathBase}/streaming-cookie-tempdata-persistence"); Browser.Exists(By.Id("streaming-complete")); Navigate($"{ServerPathBase}/tempdata"); diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/StreamingCookieTempDataPersistence.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/StreamingCookieTempDataPersistence.razor new file mode 100644 index 000000000000..ce29c09b2f7a --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/StreamingCookieTempDataPersistence.razor @@ -0,0 +1,38 @@ +@page "/streaming-cookie-tempdata-persistence" +@attribute [StreamRendering] + +

Streaming Cookie TempData Persistence Test

+ +@if (_done) +{ +

Done

+} +else +{ +

Waiting for streaming...

+} + +@code { + private bool _done; + + [SupplyParameterFromTempData] + private string SupplyParameterFromTempDataValue { get; set; } = string.Empty; + + [CascadingParameter] + public ITempData? TempData { get; set; } + + protected override async Task OnInitializedAsync() + { + // The await is critical: it causes the component to enter the streaming phase. + // Values set AFTER the await are written once the response has already started, + // which the cookie TempData provider cannot persist. + await Task.Delay(100); + + SupplyParameterFromTempDataValue = "tempdata-set-during-streaming"; + if (TempData is not null) + { + TempData["Message"] = "streaming-tempdata-message"; + } + _done = true; + } +} From 32452ee7c61108ce655976422b94e2ec3e5b92d1 Mon Sep 17 00:00:00 2001 From: Daria Tiurina Date: Wed, 8 Jul 2026 11:12:48 +0200 Subject: [PATCH 3/4] Fix --- .../Endpoints/src/TempData/CookieTempDataProvider.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Components/Endpoints/src/TempData/CookieTempDataProvider.cs b/src/Components/Endpoints/src/TempData/CookieTempDataProvider.cs index 3bd516c304ba..e43420a5636c 100644 --- a/src/Components/Endpoints/src/TempData/CookieTempDataProvider.cs +++ b/src/Components/Endpoints/src/TempData/CookieTempDataProvider.cs @@ -68,6 +68,10 @@ public CookieTempDataProvider( try { var decodeStatus = Base64Url.DecodeFromChars(serializedDataFromCookie, decodeBuffer, out _, out var bytesWritten); + if (decodeStatus != OperationStatus.Done) + { + throw new FormatException("The TempData cookie did not contain valid Base64Url-encoded data."); + } var protectedBytes = decodeBuffer[..bytesWritten]; Dictionary? dataFromCookie; From 09fd3e45ed781a803a35c87a58bca93c33bc77dc Mon Sep 17 00:00:00 2001 From: Daria Tiurina Date: Wed, 8 Jul 2026 13:08:02 +0200 Subject: [PATCH 4/4] Feedback --- src/Components/Endpoints/src/SessionResolver.cs | 3 --- .../Endpoints/src/TempData/CookieTempDataProvider.cs | 2 -- .../StreamingCookieTempDataPersistence.razor | 9 ++++----- .../StreamingRendering/StreamingSessionPersistence.razor | 7 +++---- 4 files changed, 7 insertions(+), 14 deletions(-) diff --git a/src/Components/Endpoints/src/SessionResolver.cs b/src/Components/Endpoints/src/SessionResolver.cs index 0c125ad25eac..c3a1fd1e84c3 100644 --- a/src/Components/Endpoints/src/SessionResolver.cs +++ b/src/Components/Endpoints/src/SessionResolver.cs @@ -11,9 +11,6 @@ internal static class SessionResolver // features surface the same InvalidOperationException when the session is unavailable. internal const string SessionNotConfiguredMessage = "Session has not been configured for this application or request."; - // Fails fast the first time the session is touched and found unavailable, whether - // session middleware isn't configured or the feature exposes a null session. - // Callers that legitimately have no HttpContext (interactive rendering) must not call this. internal static ISession GetRequiredSession(HttpContext httpContext) { var session = httpContext.Session; diff --git a/src/Components/Endpoints/src/TempData/CookieTempDataProvider.cs b/src/Components/Endpoints/src/TempData/CookieTempDataProvider.cs index e43420a5636c..172ed33f2a9c 100644 --- a/src/Components/Endpoints/src/TempData/CookieTempDataProvider.cs +++ b/src/Components/Endpoints/src/TempData/CookieTempDataProvider.cs @@ -55,8 +55,6 @@ public CookieTempDataProvider( return ReadOnlyDictionary.Empty; } - // Only the decode/unprotect/deserialize of untrusted cookie data is guarded: data errors - // fall back to empty, while unexpected errors surface instead of being silently swallowed. try { byte[]? rentedDecodeBuffer = null; diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/StreamingCookieTempDataPersistence.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/StreamingCookieTempDataPersistence.razor index ce29c09b2f7a..79c5c329794a 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/StreamingCookieTempDataPersistence.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/StreamingCookieTempDataPersistence.razor @@ -1,4 +1,4 @@ -@page "/streaming-cookie-tempdata-persistence" +@page "/streaming-cookie-tempdata-persistence" @attribute [StreamRendering]

Streaming Cookie TempData Persistence Test

@@ -23,10 +23,7 @@ else protected override async Task OnInitializedAsync() { - // The await is critical: it causes the component to enter the streaming phase. - // Values set AFTER the await are written once the response has already started, - // which the cookie TempData provider cannot persist. - await Task.Delay(100); + await EnterStreamingPhaseAsync(); SupplyParameterFromTempDataValue = "tempdata-set-during-streaming"; if (TempData is not null) @@ -35,4 +32,6 @@ else } _done = true; } + + private static Task EnterStreamingPhaseAsync() => Task.Delay(100); } diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/StreamingSessionPersistence.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/StreamingSessionPersistence.razor index a9f3ba676c45..8ef84f618f19 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/StreamingSessionPersistence.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/StreamingSessionPersistence.razor @@ -26,10 +26,7 @@ else protected override async Task OnInitializedAsync() { - // The await is critical: it causes the component to enter the streaming phase. - // Values set AFTER the await are the ones that were silently lost with the old - // OnStarting-based persistence, because OnStarting fires before streaming begins. - await Task.Delay(100); + await EnterStreamingPhaseAsync(); Email = "set-during-streaming"; SupplyParameterFromTempDataValue = "tempdata-set-during-streaming"; @@ -39,4 +36,6 @@ else } _done = true; } + + private static Task EnterStreamingPhaseAsync() => Task.Delay(100); }