diff --git a/code-reviews/SiteRuntime/findings.md b/code-reviews/SiteRuntime/findings.md index 8294056..06b331b 100644 --- a/code-reviews/SiteRuntime/findings.md +++ b/code-reviews/SiteRuntime/findings.md @@ -8,7 +8,7 @@ | Last reviewed | 2026-05-16 | | Reviewer | claude-agent | | Commit reviewed | `9c60592` | -| Open findings | 5 | +| Open findings | 0 | ## Summary @@ -521,7 +521,7 @@ literal/identifier non-detection, allowed-exception resolution); all 39 existing |--|--| | Severity | Low | | Category | Concurrency & thread safety | -| Status | Open | +| Status | Resolved | | Location | `src/ScadaLink.SiteRuntime/Scripts/ScopeAccessors.cs:28` | **Description** @@ -543,7 +543,18 @@ internal-only and exposing only the async API. **Resolution** -_Unresolved._ +Resolved 2026-05-16 (`pending commit`): re-triaged against the current source — the +finding's own recommendation states the blocking is *acceptable* once SiteRuntime-009's +dedicated script dispatcher exists, and SiteRuntime-009 is now Resolved +(`ScriptExecutionActor`/`AlarmExecutionActor` run script bodies on the dedicated +`ScriptExecutionScheduler` threads, confirmed in `ScriptExecutionActor.cs:74`). A +blocked accessor therefore can no longer starve the shared `ThreadPool` or Akka +dispatchers — only a dedicated script thread. The remaining defect was the misleading +class XML comment, which only said "Reads block on the actor Ask" with no thread-model +context. The `AttributeAccessor` XML doc now documents the dispatcher containment +(SiteRuntime-009) explicitly and still steers authors to the async `GetAsync`/`SetAsync` +variants. No behavioural change — this is a documentation finding; existing +`ScopeAccessorTests` continue to pass. ### SiteRuntime-013 — `HandleUnsubscribeDebugView` does nothing despite documented behaviour @@ -551,7 +562,7 @@ _Unresolved._ |--|--| | Severity | Low | | Category | Documentation & comments | -| Status | Open | +| Status | Resolved | | Location | `src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs:414` | **Description** @@ -573,7 +584,18 @@ comment to state explicitly that subscription teardown is handled by **Resolution** -_Unresolved._ +Resolved 2026-05-16 (`pending commit`): root cause confirmed — the Instance Actor +holds no per-subscriber state, so `HandleUnsubscribeDebugView` genuinely has nothing to +remove; the real debug-stream subscription lifecycle lives in `SiteStreamManager` +(Subscribe/Unsubscribe/RemoveSubscriber). The recommendation's "correct the XML comment" +option was taken (removing the handler would still leave `UnsubscribeDebugViewRequest` +routed here from `DeploymentManagerActor.RouteDebugViewUnsubscribe`, and the no-op +acknowledgement is harmless). The XML doc on `HandleUnsubscribeDebugView` now states +explicitly that it is a deliberate no-op acknowledgement and that teardown is handled by +`SiteStreamManager`; the log message likewise notes "(no-op; subscription teardown +handled by SiteStreamManager)". This is a documentation-only finding with no observable +behaviour to regression-test, so no new test was added; the existing +`InstanceActor`/debug-view tests continue to pass. ### SiteRuntime-014 — Trigger-expression evaluation blocks the coordinator actor thread @@ -581,7 +603,7 @@ _Unresolved._ |--|--| | Severity | Low | | Category | Akka.NET conventions | -| Status | Open | +| Status | Deferred | | Location | `src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs:219`, `src/ScadaLink.SiteRuntime/Actors/AlarmActor.cs:389` | **Description** @@ -605,7 +627,26 @@ without the Roslyn scripting `RunAsync` machinery. **Resolution** -_Unresolved._ +Deferred 2026-05-16 (`pending commit`): root cause confirmed — `EvaluateExpressionTrigger` +(ScriptActor) and `EvaluateExpression` (AlarmActor) call +`_compiledTriggerExpression.RunAsync(...).GetAwaiter().GetResult()` directly inside the +`AttributeValueChanged` handler, on the coordinator actor's default (thread-pool-backed) +dispatcher, blocking the mailbox for up to the 2 s timeout. Re-triaged from Open to +**Deferred** rather than fixed: neither recommended fix stays cleanly in-module without +a design decision. (a) **Off-thread eval + pipe-back** changes the actor's concurrency +model — the evaluation carries edge-tracking state (`_lastExpressionResult`) and a +mutable `_attributeSnapshot`; multiple `AttributeValueChanged` messages can arrive while +an evaluation is in flight, so a correct fix must decide overlapping-evaluation +semantics (coalesce / serialize / drop) and the snapshot-coherence contract — a behaviour +change to the trigger model. (b) **Pre-compile to a plain delegate** would require +changing the compilation contract: the trigger expression is produced as a Roslyn +`Script` by `ScriptCompilationService.CompileTriggerExpression`, which is also +the security boundary (SiteRuntime-011 trust validation); swapping the artifact type is +a cross-component change touching the Template Engine / Deployment Manager compile +pipeline. Given Low severity, a bounded 2 s worst case, and the inline note that trigger +expressions are trusted, compile-checked, and expected to be cheap, this is left +Deferred pending a design decision on trigger-evaluation concurrency rather than forcing +an out-of-scope or messaging-contract-changing fix. ### SiteRuntime-015 — `LoggerFactory` created per Instance Actor and never disposed @@ -613,7 +654,7 @@ _Unresolved._ |--|--| | Severity | Low | | Category | Performance & resource management | -| Status | Open | +| Status | Resolved | | Location | `src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs:746` | **Description** @@ -634,7 +675,18 @@ up per child. Do not create a fresh `LoggerFactory` in a hot creation path. **Resolution** -_Unresolved._ +Resolved 2026-05-16 (`pending commit`): root cause confirmed — `CreateInstanceActor` +did `new LoggerFactory()` per Instance Actor, never disposed, and detached from the +host's logging providers. `DeploymentManagerActor` now holds a single `_loggerFactory` +field, resolved once in the constructor from (in order) a new optional `ILoggerFactory` +constructor parameter, the injected `IServiceProvider`, or `NullLoggerFactory.Instance` +as a last resort — never a per-instance allocation. `CreateInstanceActor` mints the +`InstanceActor` logger from this shared factory, so loggers are routed through the +application's configured providers and no factory leaks. Regression test: +`DeploymentManagerLoggerFactoryTests.CreateInstanceActor_ReusesInjectedLoggerFactory_ForEveryInstance` +injects a counting `ILoggerFactory` and asserts it is used once per created Instance +Actor — confirmed to fail (0 calls) against the pre-fix `new LoggerFactory()` code and +pass after the fix. ### SiteRuntime-016 — Short-lived execution actors, replication actor, and repositories are untested @@ -642,7 +694,7 @@ _Unresolved._ |--|--| | Severity | Low | | Category | Testing coverage | -| Status | Open | +| Status | Resolved | | Location | `tests/ScadaLink.SiteRuntime.Tests/` | **Description** @@ -666,4 +718,18 @@ synthetic-ID lookup, missing-row behaviour). **Resolution** -_Unresolved._ +Resolved 2026-05-16 (`pending commit`): re-triaged against the current test sources — +`SiteExternalSystemRepository` and `SiteNotificationRepository` are already covered by +`Repositories/SiteRepositoryTests.cs` (added when SiteRuntime-006/007 were resolved: +round-trip read and synthetic-ID-stable-across-restart). The execution-actor gap is now +closed by a new `Actors/ExecutionActorTests.cs` — six tests covering +`ScriptExecutionActor` (success → `ScriptCallResult` reply + PoisonPill self-stop; +script-throws → failure reply + stop; cooperative timeout → failure reply + stop; +no-`replyTo` fire-and-forget still self-stops) and `AlarmExecutionActor` (success → +self-stop; on-trigger throws → still self-stops). `SiteReplicationActor` is *not* covered +here: it depends on `Cluster.Get(Context.System)` and so requires a clustered +`ActorSystem` HOCON harness that does not yet exist in this test project — adding that +harness is a larger test-infrastructure task tracked separately and out of scope for a +Low-severity coverage finding; the highest-value untested paths the finding called out +(script timeout/failure/reply/self-stop) are now covered. Full module suite: 192 tests +green. diff --git a/src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs b/src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs index 74fdb11..e28a32a 100644 --- a/src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs +++ b/src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs @@ -34,6 +34,14 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers private readonly SiteStreamManager? _streamManager; private readonly SiteRuntimeOptions _options; private readonly ILogger _logger; + /// + /// Shared logger factory used to mint loggers + /// (SiteRuntime-015). Reused across every + /// call rather than newing a per-instance factory that is never disposed. + /// When the host injects its configured factory the Instance Actor logs are + /// routed through the application's logging providers. + /// + private readonly ILoggerFactory _loggerFactory; private readonly IActorRef? _dclManager; private readonly IActorRef? _replicationActor; private readonly ISiteHealthCollector? _healthCollector; @@ -59,7 +67,8 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers IActorRef? dclManager = null, IActorRef? replicationActor = null, ISiteHealthCollector? healthCollector = null, - IServiceProvider? serviceProvider = null) + IServiceProvider? serviceProvider = null, + ILoggerFactory? loggerFactory = null) { _storage = storage; _compilationService = compilationService; @@ -71,6 +80,13 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers _healthCollector = healthCollector; _serviceProvider = serviceProvider; _logger = logger; + // SiteRuntime-015: reuse a single logger factory for all Instance Actors. + // Prefer an explicitly injected factory, fall back to one resolved from + // the service provider, and only as a last resort use NullLoggerFactory — + // never a per-instance `new LoggerFactory()` that leaks undisposed. + _loggerFactory = loggerFactory + ?? serviceProvider?.GetService(typeof(ILoggerFactory)) as ILoggerFactory + ?? Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance; // Lifecycle commands Receive(HandleDeploy); @@ -942,7 +958,8 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers return; } - var loggerFactory = new LoggerFactory(); + // SiteRuntime-015: reuse the shared, host-configured logger factory + // instead of allocating (and leaking) a fresh LoggerFactory per instance. var props = Props.Create(() => new InstanceActor( instanceName, configJson, @@ -951,7 +968,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers _sharedScriptLibrary, _streamManager, _options, - loggerFactory.CreateLogger(), + _loggerFactory.CreateLogger(), _dclManager, _healthCollector, _serviceProvider)); diff --git a/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs b/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs index d5b5782..53da639 100644 --- a/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs +++ b/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs @@ -494,12 +494,19 @@ public class InstanceActor : ReceiveActor } /// - /// WP-25: Debug view unsubscribe — removes subscription. + /// WP-25: Debug view unsubscribe (SiteRuntime-013). + /// This handler is a deliberate no-op acknowledgement: the Instance Actor holds + /// no per-subscriber state. The real debug-stream subscription lifecycle lives in + /// + /// (Subscribe / Unsubscribe / RemoveSubscriber); the gRPC stream is torn down + /// there when the central side cancels the call. Nothing is removed here. /// private void HandleUnsubscribeDebugView(UnsubscribeDebugViewRequest request) { + // No subscription state in the Instance Actor — see the XML doc above. _logger.LogDebug( - "Debug view unsubscribe for {Instance}, correlationId={Id}", + "Debug view unsubscribe for {Instance}, correlationId={Id} " + + "(no-op; subscription teardown handled by SiteStreamManager)", _instanceUniqueName, request.CorrelationId); } diff --git a/src/ScadaLink.SiteRuntime/Scripts/ScopeAccessors.cs b/src/ScadaLink.SiteRuntime/Scripts/ScopeAccessors.cs index 4c8a059..13e4974 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/ScopeAccessors.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/ScopeAccessors.cs @@ -4,8 +4,18 @@ namespace ScadaLink.SiteRuntime.Scripts; /// Scope-aware view onto the instance's attributes, anchored at a path prefix. /// Attributes["X"] on the root scope resolves to canonical name "X"; /// on a composition with prefix "TempSensor" it resolves to "TempSensor.X". -/// Reads block on the actor Ask; async variants are provided for callers -/// that prefer to await explicitly. +/// +/// +/// Thread-model note (SiteRuntime-012): the indexer get/set block synchronously +/// on the Instance Actor Ask (and, for data-connected attributes, the DCL +/// round-trip). This is safe because script bodies execute on the dedicated +/// threads (SiteRuntime-009), not the +/// shared — so a blocked accessor +/// cannot starve unrelated Akka dispatchers or HTTP request handling. The async +/// variants (/) are still preferred +/// where the script can await, as they avoid holding a dedicated thread idle for +/// the duration of each round-trip. +/// /// public class AttributeAccessor { diff --git a/tests/ScadaLink.SiteRuntime.Tests/Actors/DeploymentManagerLoggerFactoryTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Actors/DeploymentManagerLoggerFactoryTests.cs new file mode 100644 index 0000000..6507da3 --- /dev/null +++ b/tests/ScadaLink.SiteRuntime.Tests/Actors/DeploymentManagerLoggerFactoryTests.cs @@ -0,0 +1,115 @@ +using Akka.Actor; +using Akka.TestKit.Xunit2; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using ScadaLink.Commons.Messages.Deployment; +using ScadaLink.Commons.Types.Enums; +using ScadaLink.Commons.Types.Flattening; +using ScadaLink.SiteRuntime.Actors; +using ScadaLink.SiteRuntime.Persistence; +using ScadaLink.SiteRuntime.Scripts; +using System.Text.Json; + +namespace ScadaLink.SiteRuntime.Tests.Actors; + +/// +/// Regression test for SiteRuntime-015 — must +/// reuse a single, injected for every Instance Actor it +/// creates rather than newing (and leaking) a fresh per +/// instance. +/// +public class DeploymentManagerLoggerFactoryTests : TestKit, IDisposable +{ + private readonly SiteStorageService _storage; + private readonly ScriptCompilationService _compilationService; + private readonly SharedScriptLibrary _sharedScriptLibrary; + private readonly string _dbFile; + + public DeploymentManagerLoggerFactoryTests() + { + _dbFile = Path.Combine(Path.GetTempPath(), $"dm-loggerfactory-test-{Guid.NewGuid():N}.db"); + _storage = new SiteStorageService( + $"Data Source={_dbFile}", + NullLogger.Instance); + _storage.InitializeAsync().GetAwaiter().GetResult(); + _compilationService = new ScriptCompilationService( + NullLogger.Instance); + _sharedScriptLibrary = new SharedScriptLibrary( + _compilationService, NullLogger.Instance); + } + + void IDisposable.Dispose() + { + Shutdown(); + try { File.Delete(_dbFile); } catch { /* cleanup */ } + } + + private static string MakeConfigJson(string instanceName) + { + var config = new FlattenedConfiguration + { + InstanceUniqueName = instanceName, + Attributes = + [ + new ResolvedAttribute { CanonicalName = "TestAttr", Value = "1", DataType = "Int32" } + ] + }; + return JsonSerializer.Serialize(config); + } + + /// + /// Counts calls and records whether + /// the factory was disposed. A passing test proves the single injected factory + /// is the one used for every Instance Actor. + /// + private sealed class CountingLoggerFactory : ILoggerFactory + { + public int CreateLoggerCalls; + public bool Disposed; + + public ILogger CreateLogger(string categoryName) + { + Interlocked.Increment(ref CreateLoggerCalls); + return NullLogger.Instance; + } + + public void AddProvider(ILoggerProvider provider) { } + public void Dispose() => Disposed = true; + } + + [Fact] + public async Task CreateInstanceActor_ReusesInjectedLoggerFactory_ForEveryInstance() + { + // Pre-populate several enabled instances so startup creates multiple + // Instance Actors. + const int instanceCount = 6; + for (int i = 0; i < instanceCount; i++) + { + var name = $"Inst{i}"; + await _storage.StoreDeployedConfigAsync(name, MakeConfigJson(name), $"d{i}", $"h{i}", true); + } + + var loggerFactory = new CountingLoggerFactory(); + + var actor = ActorOf(Props.Create(() => new DeploymentManagerActor( + _storage, + _compilationService, + _sharedScriptLibrary, + null, + new SiteRuntimeOptions { StartupBatchSize = 100, StartupBatchDelayMs = 5 }, + NullLogger.Instance, + null, + null, + null, + null, + loggerFactory))); + + // Allow async startup (load configs + staggered creation). + await Task.Delay(2000); + + // Every Instance Actor logger must come from the single injected factory. + // Before the fix, each CreateInstanceActor allocated its own LoggerFactory, + // so the injected factory would never be touched (CreateLoggerCalls == 0). + Assert.Equal(instanceCount, loggerFactory.CreateLoggerCalls); + } +} diff --git a/tests/ScadaLink.SiteRuntime.Tests/Actors/ExecutionActorTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Actors/ExecutionActorTests.cs new file mode 100644 index 0000000..1313359 --- /dev/null +++ b/tests/ScadaLink.SiteRuntime.Tests/Actors/ExecutionActorTests.cs @@ -0,0 +1,171 @@ +using Akka.Actor; +using Akka.TestKit.Xunit2; +using Microsoft.CodeAnalysis.CSharp.Scripting; +using Microsoft.CodeAnalysis.Scripting; +using Microsoft.Extensions.Logging.Abstractions; +using ScadaLink.Commons.Messages.ScriptExecution; +using ScadaLink.Commons.Types.Enums; +using ScadaLink.Commons.Types.Scripts; +using ScadaLink.SiteRuntime.Actors; +using ScadaLink.SiteRuntime.Scripts; + +namespace ScadaLink.SiteRuntime.Tests.Actors; + +/// +/// Regression coverage for SiteRuntime-016 — the short-lived execution actors +/// (, ) were +/// previously untested. Covers success, exception, timeout, Ask-reply, and the +/// PoisonPill self-stop after completion. +/// +public class ExecutionActorTests : TestKit, IDisposable +{ + private readonly SharedScriptLibrary _sharedLibrary; + private readonly ScriptCompilationService _compilationService; + + public ExecutionActorTests() + { + _compilationService = new ScriptCompilationService( + NullLogger.Instance); + _sharedLibrary = new SharedScriptLibrary( + _compilationService, NullLogger.Instance); + } + + void IDisposable.Dispose() => Shutdown(); + + private static Script CompileScript(string code) + { + var scriptOptions = ScriptOptions.Default + .WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly) + .WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks"); + var script = CSharpScript.Create(code, scriptOptions, typeof(ScriptGlobals)); + script.Compile(); + return script; + } + + private static SiteRuntimeOptions Options(int timeoutSeconds = 30) + => new() { MaxScriptCallDepth = 10, ScriptExecutionTimeoutSeconds = timeoutSeconds }; + + // ── ScriptExecutionActor ── + + [Fact] + public void ScriptExecutionActor_Success_RepliesWithResultAndStops() + { + var compiled = CompileScript("return 7 * 6;"); + var replyTo = CreateTestProbe(); + var instanceActor = CreateTestProbe(); + + var exec = ActorOf(Props.Create(() => new ScriptExecutionActor( + "Answer", "Inst1", compiled, null, 0, + instanceActor.Ref, _sharedLibrary, Options(), + replyTo.Ref, "corr-1", NullLogger.Instance, + ScriptScope.Root, null, null))); + + Watch(exec); + + var result = replyTo.ExpectMsg(TimeSpan.FromSeconds(10)); + Assert.True(result.Success); + Assert.Equal("corr-1", result.CorrelationId); + Assert.Equal(42, result.ReturnValue); + + // The actor must PoisonPill itself once execution completes. + ExpectTerminated(exec, TimeSpan.FromSeconds(5)); + } + + [Fact] + public void ScriptExecutionActor_ScriptThrows_RepliesFailureAndStops() + { + var compiled = CompileScript("throw new InvalidOperationException(\"boom\");"); + var replyTo = CreateTestProbe(); + var instanceActor = CreateTestProbe(); + + var exec = ActorOf(Props.Create(() => new ScriptExecutionActor( + "Bad", "Inst1", compiled, null, 0, + instanceActor.Ref, _sharedLibrary, Options(), + replyTo.Ref, "corr-2", NullLogger.Instance, + ScriptScope.Root, null, null))); + + Watch(exec); + + var result = replyTo.ExpectMsg(TimeSpan.FromSeconds(10)); + Assert.False(result.Success); + Assert.Equal("corr-2", result.CorrelationId); + Assert.Contains("boom", result.ErrorMessage); + + ExpectTerminated(exec, TimeSpan.FromSeconds(5)); + } + + [Fact] + public void ScriptExecutionActor_Timeout_RepliesFailureAndStops() + { + // A long busy loop that observes the cancellation token so the + // 1-second timeout fires cooperatively. + var compiled = CompileScript( + "while (true) { await System.Threading.Tasks.Task.Delay(50, CancellationToken); }"); + var replyTo = CreateTestProbe(); + var instanceActor = CreateTestProbe(); + + var exec = ActorOf(Props.Create(() => new ScriptExecutionActor( + "Slow", "Inst1", compiled, null, 0, + instanceActor.Ref, _sharedLibrary, Options(timeoutSeconds: 1), + replyTo.Ref, "corr-3", NullLogger.Instance, + ScriptScope.Root, null, null))); + + Watch(exec); + + var result = replyTo.ExpectMsg(TimeSpan.FromSeconds(10)); + Assert.False(result.Success); + Assert.Contains("timed out", result.ErrorMessage); + + ExpectTerminated(exec, TimeSpan.FromSeconds(5)); + } + + [Fact] + public void ScriptExecutionActor_NoReplyTo_StillStopsAfterCompletion() + { + var compiled = CompileScript("return 1;"); + var instanceActor = CreateTestProbe(); + + // ActorRefs.Nobody as replyTo — fire-and-forget execution. + var exec = ActorOf(Props.Create(() => new ScriptExecutionActor( + "FireForget", "Inst1", compiled, null, 0, + instanceActor.Ref, _sharedLibrary, Options(), + ActorRefs.Nobody, "corr-4", NullLogger.Instance, + ScriptScope.Root, null, null))); + + Watch(exec); + ExpectTerminated(exec, TimeSpan.FromSeconds(5)); + } + + // ── AlarmExecutionActor ── + + [Fact] + public void AlarmExecutionActor_Success_StopsAfterCompletion() + { + var compiled = CompileScript("return 0;"); + var instanceActor = CreateTestProbe(); + + var exec = ActorOf(Props.Create(() => new AlarmExecutionActor( + "HiTemp", "Inst1", AlarmLevel.High, 5, "High temperature", + compiled, instanceActor.Ref, _sharedLibrary, Options(), + NullLogger.Instance))); + + Watch(exec); + ExpectTerminated(exec, TimeSpan.FromSeconds(5)); + } + + [Fact] + public void AlarmExecutionActor_ScriptThrows_StillStops() + { + var compiled = CompileScript("throw new System.Exception(\"alarm-boom\");"); + var instanceActor = CreateTestProbe(); + + var exec = ActorOf(Props.Create(() => new AlarmExecutionActor( + "HiTemp", "Inst1", AlarmLevel.High, 5, "High temperature", + compiled, instanceActor.Ref, _sharedLibrary, Options(), + NullLogger.Instance))); + + Watch(exec); + // Even on a throwing on-trigger body, the actor must self-stop. + ExpectTerminated(exec, TimeSpan.FromSeconds(5)); + } +}