diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/MeshTransportOptionsValidator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/MeshTransportOptionsValidator.cs index ad47fc88..5641672e 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/MeshTransportOptionsValidator.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/MeshTransportOptionsValidator.cs @@ -94,9 +94,10 @@ public sealed class MeshTransportOptionsValidator : IValidateOptions()); var isDriver = Array.IndexOf(roles, RoleParser.Driver) >= 0; + var isAdmin = Array.IndexOf(roles, RoleParser.Admin) >= 0; if (isGrpc && isDriver) { @@ -166,6 +168,37 @@ public sealed class MeshTransportOptionsValidator : IValidateOptions THROWS on an unparseable value, and a validator whose whole job is to + // produce a readable failure must not be the thing that crashes on a typo. Missing, + // unparseable and non-positive all mean the same thing — nothing is bound — so they share + // one message. + var configServePort = _configuration[ + $"{ConfigServeOptions.SectionName}:{nameof(ConfigServeOptions.GrpcListenPort)}"]; + if (!int.TryParse(configServePort, out var port) || port <= 0) + { + errors.Add( + $"{ConfigServeOptions.SectionName}:" + + $"{nameof(ConfigServeOptions.GrpcListenPort)} is not set to a positive port. " + + $"Under {MeshTransportOptions.ModeGrpc} an admin node serves the central " + + "command endpoint on that listener, and without it every node's deployment ack " + + "has nowhere to land — the deploy would time out naming nodes that applied " + + "perfectly. Supply it via the environment " + + $"({ConfigServeOptions.SectionName}__" + + $"{nameof(ConfigServeOptions.GrpcListenPort)})."); + } + } + return errors.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(string.Join(" ", errors)); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Grpc/CentralCommandGrpcService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Grpc/CentralCommandGrpcService.cs new file mode 100644 index 00000000..1728ff5d --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Grpc/CentralCommandGrpcService.cs @@ -0,0 +1,118 @@ +using Akka.Actor; +using Akka.Hosting; +using Grpc.Core; +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh; +using ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1; +using ZB.MOM.WW.OtOpcUa.ControlPlane; +// The generated service container is itself named CentralCommandService; alias its nested server +// base so this impl can keep the natural name without colliding with the generated type. +using GeneratedServiceBase = + ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1.CentralCommandService.CentralCommandServiceBase; +// Akka.Actor (needed for the single-argument Tell) also defines a Status. Every Status in this file +// is the gRPC one, so bind the name rather than qualifying each use. +using Status = Grpc.Core.Status; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Grpc; + +/// +/// Central-side ingress for a node's deployment acknowledgment (ClusterClient→gRPC migration +/// Phase 1B). Decodes an and hands it to the node-local +/// CentralCommunicationActor — the same actor an Akka ClusterClient receptionist delivered +/// to, whose forward-to-the-coordinator path is untouched. Registered only on admin-role nodes +/// and only when ConfigServe:GrpcListenPort > 0. +/// +/// +/// +/// The actor is resolved per call, not in the constructor. Kestrel answers before +/// Akka.Hosting has finished spawning the actor graph, so a ctor lookup would throw +/// during the boot window and surface as an opaque Unknown. Resolving per call turns +/// that window into — the retryable status — so the +/// node's next attempt lands rather than the deploy recording a permanent failure against a +/// node that applied fine. +/// +/// +/// Decoding is graded before the registry. A message that cannot decode is +/// even when the actor is also missing: Unavailable +/// invites a retry, and retrying a message that can never decode is a loop. The decode itself +/// never guesses (see ) — an unspecified outcome, an +/// unparseable id or a blank node name is refused rather than resolved to a plausible value, +/// because the deploy path seals a revision on Applied and a guessed ack seals a +/// deployment nobody applied. +/// +/// +/// The rejection log carries the wire correlation id verbatim. The inner ids are +/// exactly what may have failed to parse, so the only value guaranteed readable at that point +/// is the raw string — which is what an operator has to correlate the rejection against the +/// node's own log. +/// +/// +/// One-way semantics, preserved. Tell, not Ask: the returned +/// means "handed to the comm actor", never "the deployment is +/// complete" — the same promise the receptionist made. The comm actor forwards to the +/// coordinator singleton proxy, so an ack landing on either central node routes identically. +/// +/// +public sealed class CentralCommandGrpcService : GeneratedServiceBase +{ + private readonly ActorRegistry _registry; + private readonly ILogger _log; + + /// Initializes a new instance of the class. + /// The actor registry holding the node's CentralCommunicationActor. + /// Logger. + public CentralCommandGrpcService(ActorRegistry registry, ILogger log) + { + _registry = registry; + _log = log; + } + + /// + public override Task ReportApply(ApplyAckMessage request, ServerCallContext context) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(context); + + ApplyAck ack; + try + { + // Exactly the three the mapper documents: an unspecified/unknown outcome + // (InvalidOperationException), an unparseable id (FormatException) and a blank node id + // (ArgumentException). Deliberately not a blanket catch — anything else escaping the + // mapper is a bug on this side, and reporting it to the caller as their InvalidArgument + // would send an operator to debug a node that sent a perfectly good ack. + ack = MeshTransportDtoMapper.FromProto(request); + } + catch (Exception ex) when (ex is InvalidOperationException or FormatException or ArgumentException) + { + // Logged with the RAW correlation id: the decode may have failed on the ids themselves. + _log.LogWarning( + ex, + "Rejected an ApplyAck (correlation '{CorrelationId}') from {Peer}: it could not be " + + "decoded, so the deployment it names is NOT acknowledged on this node", + request.CorrelationId, context.Peer); + throw new RpcException(new Status(StatusCode.InvalidArgument, "undecodable ApplyAck")); + } + + if (!_registry.TryGet(out var actor)) + { + _log.LogWarning( + "Received an ApplyAck for {DeploymentId} from {NodeId} before the central " + + "communication actor was registered on this node; refusing with Unavailable so the " + + "caller retries rather than recording a failure against a node that applied", + ack.DeploymentId, ack.NodeId); + throw new RpcException(new Status(StatusCode.Unavailable, "central command actor not ready")); + } + + // Tell, not Ask: the RPC's own response is the acknowledgment, and the comm actor's existing + // Forward-to-coordinator path is what it always was. + actor.Tell(ack); + + _log.LogDebug( + "ApplyAck for {DeploymentId} from {NodeId} ({Outcome}) handed to the central communication actor", + ack.DeploymentId, ack.NodeId, ack.Outcome); + + return Task.FromResult(new ReportAck()); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index 2fb407a3..80f3048b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -744,6 +744,16 @@ if (hasDriver && syncListenPort > 0) if (hasAdmin && configServeGrpcPort > 0) { app.MapGrpcService(); + + // Central command ingress (ClusterClient→gRPC migration): the node→central deployment ack. It + // rides the SAME listener rather than claiming a port of its own — a second cleartext h2c port + // would buy nothing but another firewall rule, and both endpoints are admin-role and dialled by + // the same driver nodes. Deliberately NOT gated on MeshTransport:Mode: mapping it under Dps + // costs nothing (nothing dials it) whereas gating it would mean flipping the mode on a running + // fleet requires a central restart before any node can ack — the same reasoning that registers + // CentralCommunicationActor in both modes. CentralCommandAuthInterceptor (added at AddGrpc + // above) is the ONLY inbound auth and fail-closes per cluster. + app.MapGrpcService(); } // Driver-node live-telemetry stream (per-cluster mesh Phase 5). Central dials in; gated by diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/MeshTransportOptionsValidatorTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/MeshTransportOptionsValidatorTests.cs index 1fb4ce46..e3eab46b 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/MeshTransportOptionsValidatorTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/MeshTransportOptionsValidatorTests.cs @@ -20,9 +20,18 @@ namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests; [Collection(RoleEnvironmentCollection.Name)] public class MeshTransportOptionsValidatorTests { - private static ValidateOptionsResult Validate(MeshTransportOptions o, params string[] roles) + private static ValidateOptionsResult Validate(MeshTransportOptions o, params string[] roles) => + Validate(o, new Dictionary(), roles); + + /// + /// As , plus arbitrary configuration + /// keys — the admin-side rule cross-reads ConfigServe:GrpcListenPort, which lives + /// outside this options class entirely. + /// + private static ValidateOptionsResult Validate( + MeshTransportOptions o, Dictionary extra, params string[] roles) { - var pairs = new Dictionary(); + var pairs = new Dictionary(extra); for (var i = 0; i < roles.Length; i++) { pairs[$"Cluster:Roles:{i}"] = roles[i]; @@ -34,6 +43,9 @@ public class MeshTransportOptionsValidatorTests .Validate(MeshTransportOptions.SectionName, o); } + private static Dictionary ConfigServePort(string? value) => + new() { ["ConfigServe:GrpcListenPort"] = value }; + private static MeshTransportOptions WellFormedGrpc() => new() { Mode = MeshTransportOptions.ModeGrpc, @@ -298,11 +310,105 @@ public class MeshTransportOptionsValidatorTests // The node-side surface is what a DRIVER uses to dial central and post its ack. An admin-only // central node dials nodes instead and resolves per-cluster keys at runtime, so requiring // these here would make an admin node carry a PSK and an endpoint list it never reads. + // (The ConfigServe port IS required of an admin node under Grpc — supplied here so this test + // stays about the node-side keys and nothing else.) Validate( new MeshTransportOptions { Mode = MeshTransportOptions.ModeGrpc }, + ConfigServePort("4055"), "admin", "cluster-MAIN").Succeeded.ShouldBeTrue(); } + [Fact] + public void Grpc_admin_node_without_a_config_serve_listener_fails() + { + // Central hosts CentralCommandService on the ConfigServe h2c listener, so an unset port means + // the service is never mapped and every node's ack has nowhere to land. Nothing errors at + // runtime — the deployment simply times out naming nodes that applied perfectly, which is the + // exact absence this validator exists to convert into a refusal to start. + var result = Validate( + new MeshTransportOptions { Mode = MeshTransportOptions.ModeGrpc }, + "admin", "cluster-MAIN"); + + result.Failed.ShouldBeTrue(); + result.FailureMessage.ShouldContain("ConfigServe:GrpcListenPort"); + result.FailureMessage.ShouldContain("ConfigServe__GrpcListenPort"); + } + + [Fact] + public void Grpc_admin_node_with_a_zero_config_serve_port_fails() + { + // 0 is the documented "disabled" value, not merely a missing key — it must fail exactly as an + // absent one does, because it binds nothing just as surely. + Validate( + new MeshTransportOptions { Mode = MeshTransportOptions.ModeGrpc }, + ConfigServePort("0"), + "admin", "cluster-MAIN").Failed.ShouldBeTrue(); + } + + [Fact] + public void Grpc_admin_node_with_an_unparseable_config_serve_port_fails() + { + // A typo'd port binds nothing, so it belongs on the same side of the line as an absent one. + Validate( + new MeshTransportOptions { Mode = MeshTransportOptions.ModeGrpc }, + ConfigServePort("40o55"), + "admin", "cluster-MAIN").Failed.ShouldBeTrue(); + } + + [Fact] + public void Grpc_admin_node_with_a_config_serve_listener_passes() + { + Validate( + new MeshTransportOptions { Mode = MeshTransportOptions.ModeGrpc }, + ConfigServePort("4055"), + "admin", "cluster-MAIN").Succeeded.ShouldBeTrue(); + } + + [Fact] + public void Grpc_driver_only_node_needs_no_config_serve_listener() + { + // A driver node dials central; it serves no central command endpoint at all, and requiring an + // admin-side listener port of it would demand configuration it never reads. + Validate(WellFormedGrpc(), "driver", "cluster-SITE-A").Succeeded.ShouldBeTrue(); + } + + [Fact] + public void Dps_admin_node_needs_no_config_serve_listener() + { + // The no-new-obligations invariant, admin side: a central node still on the default transport + // publishes on DistributedPubSub and hosts no command endpoint. + Validate( + new MeshTransportOptions { Mode = MeshTransportOptions.ModeDps }, + "admin", "cluster-MAIN").Succeeded.ShouldBeTrue(); + } + + [Fact] + public void ClusterClient_admin_node_needs_no_config_serve_listener() + { + // Contact points are required by the mode itself, independently of role — supplied so this + // test fails only if the ConfigServe rule wrongly fires outside Grpc mode. + Validate( + new MeshTransportOptions + { + Mode = MeshTransportOptions.ModeClusterClient, + CentralContactPoints = ["akka.tcp://otopcua@central-1:4053"], + }, + "admin", "cluster-MAIN").Succeeded.ShouldBeTrue(); + } + + [Fact] + public void The_admin_role_is_also_read_from_OTOPCUA_ROLES() + { + // Same roles-source divergence as the driver case (Gitea #532), on the side that hosts the + // endpoint: a central node configured only through the env var is still the node whose + // listener every ack depends on. + using (new EnvVarScope("OTOPCUA_ROLES", "admin,cluster-MAIN")) + { + Validate(new MeshTransportOptions { Mode = MeshTransportOptions.ModeGrpc }) + .Failed.ShouldBeTrue(); + } + } + [Fact] public void Grpc_role_less_node_needs_none_of_the_node_side_keys() { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Grpc/CentralCommandGrpcServiceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Grpc/CentralCommandGrpcServiceTests.cs new file mode 100644 index 00000000..8fd3682c --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Grpc/CentralCommandGrpcServiceTests.cs @@ -0,0 +1,246 @@ +using Akka.Hosting; +using Akka.TestKit.Xunit2; +using Grpc.Core; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Grpc; +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; +using ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1; +using ZB.MOM.WW.OtOpcUa.Commons.Types; +using ZB.MOM.WW.OtOpcUa.ControlPlane; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Grpc; + +/// +/// The central-side gRPC ingress for a node's deployment ack (ClusterClient→gRPC migration +/// Phase 1B). It decodes the wire message and hands it to the registered +/// CentralCommunicationActor — the same actor the ClusterClient receptionist delivered to, +/// whose forward-to-the-coordinator path is untouched. +/// +/// +/// Both failure modes matter more than the happy path. An ack that cannot be decoded must be +/// refused rather than guessed at (a wrong outcome seals a deployment nobody applied), and an ack +/// arriving before the actor exists must say so the caller +/// retries instead of recording a permanent failure against a node that is fine. +/// +public sealed class CentralCommandGrpcServiceTests : TestKit +{ + private static ApplyAckMessage WellFormed() => new() + { + DeploymentId = Guid.NewGuid().ToString("N"), + NodeId = "site-a-1", + Outcome = ApplyOutcome.Applied, + CorrelationId = Guid.NewGuid().ToString("N"), + }; + + private static CentralCommandGrpcService Service(ActorRegistry registry) => + new(registry, NullLogger.Instance); + + [Fact] + public async Task A_well_formed_ack_is_decoded_and_told_to_the_registered_actor() + { + var probe = CreateTestProbe("central-comm"); + var registry = new ActorRegistry(); + registry.Register(probe.Ref); + + var request = WellFormed(); + request.FailureReason = "disk full"; + request.Outcome = ApplyOutcome.Failed; + + var response = await Service(registry).ReportApply(request, new FakeServerCallContext()); + + response.ShouldNotBeNull(); + + var received = probe.ExpectMsg(TimeSpan.FromSeconds(3)); + received.DeploymentId.ShouldBe(DeploymentId.Parse(request.DeploymentId)); + received.NodeId.Value.ShouldBe("site-a-1"); + received.Outcome.ShouldBe(ApplyAckOutcome.Failed); + received.FailureReason.ShouldBe("disk full"); + received.CorrelationId.ToString().ShouldBe(request.CorrelationId); + } + + [Fact] + public async Task An_absent_failure_reason_arrives_as_null_and_an_empty_one_as_empty() + { + // Presence, not emptiness — the mapper's contract, asserted at the seam that actually feeds + // the coordinator. Collapsing the two would report an operator-visible blank reason as "no + // reason recorded". + var probe = CreateTestProbe("central-comm"); + var registry = new ActorRegistry(); + registry.Register(probe.Ref); + var service = Service(registry); + + await service.ReportApply(WellFormed(), new FakeServerCallContext()); + probe.ExpectMsg(TimeSpan.FromSeconds(3)).FailureReason.ShouldBeNull(); + + var blank = WellFormed(); + blank.FailureReason = ""; + await service.ReportApply(blank, new FakeServerCallContext()); + probe.ExpectMsg(TimeSpan.FromSeconds(3)).FailureReason.ShouldBe(""); + } + + [Fact] + public async Task An_unregistered_actor_yields_Unavailable() + { + // The boot window: the listener answers before Akka.Hosting has spawned the actor. Unavailable + // is the retryable status, so the node's next attempt lands instead of the deployment + // recording a permanent failure against a node that applied fine. + var ex = await Should.ThrowAsync(() => + Service(new ActorRegistry()).ReportApply(WellFormed(), new FakeServerCallContext())); + + ex.StatusCode.ShouldBe(StatusCode.Unavailable); + } + + [Fact] + public async Task An_unspecified_outcome_yields_InvalidArgument() + { + // proto3 decodes an absent enum as 0. Refusing is the whole point: the deploy path seals on + // Applied, so guessing an outcome the node never sent would seal a deployment nobody applied. + var probe = CreateTestProbe("central-comm"); + var registry = new ActorRegistry(); + registry.Register(probe.Ref); + + var request = WellFormed(); + request.Outcome = ApplyOutcome.Unspecified; + + var ex = await Should.ThrowAsync(() => + Service(registry).ReportApply(request, new FakeServerCallContext())); + + ex.StatusCode.ShouldBe(StatusCode.InvalidArgument); + probe.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); + } + + [Fact] + public async Task A_malformed_id_yields_InvalidArgument() + { + var request = WellFormed(); + request.DeploymentId = "not-a-guid"; + + var probe = CreateTestProbe("central-comm"); + var registry = new ActorRegistry(); + registry.Register(probe.Ref); + + var ex = await Should.ThrowAsync(() => + Service(registry).ReportApply(request, new FakeServerCallContext())); + + ex.StatusCode.ShouldBe(StatusCode.InvalidArgument); + probe.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); + } + + [Fact] + public async Task A_blank_node_id_yields_InvalidArgument() + { + // NodeId.Parse refuses a blank name; an ack whose node cannot be named is uncountable at the + // coordinator, so it must be refused here rather than forwarded as an anonymous ack. + var request = WellFormed(); + request.NodeId = ""; + + var probe = CreateTestProbe("central-comm"); + var registry = new ActorRegistry(); + registry.Register(probe.Ref); + + var ex = await Should.ThrowAsync(() => + Service(registry).ReportApply(request, new FakeServerCallContext())); + + ex.StatusCode.ShouldBe(StatusCode.InvalidArgument); + probe.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); + } + + [Fact] + public async Task An_undecodable_message_is_graded_before_the_registry() + { + // Ordering pin. With NO actor registered AND an undecodable message, the answer is + // InvalidArgument, not Unavailable: Unavailable invites a retry, and retrying a message that + // can never decode is a loop. The caller's fault is reported as the caller's fault. + var request = WellFormed(); + request.Outcome = ApplyOutcome.Unspecified; + + var ex = await Should.ThrowAsync(() => + Service(new ActorRegistry()).ReportApply(request, new FakeServerCallContext())); + + ex.StatusCode.ShouldBe(StatusCode.InvalidArgument); + } + + [Fact] + public async Task A_decode_failure_logs_the_raw_correlation_id() + { + // The correlation id is logged as the raw wire string precisely because the ids may be what + // failed to parse — an operator correlating a rejection cannot be given a value that only + // exists once decoding succeeded. + var logger = new CapturingLogger(); + var request = WellFormed(); + request.CorrelationId = "correlation-that-never-parses"; + request.DeploymentId = "not-a-guid"; + + await Should.ThrowAsync(() => + new CentralCommandGrpcService(new ActorRegistry(), logger) + .ReportApply(request, new FakeServerCallContext())); + + logger.Entries.Any(e => e.Message.Contains("correlation-that-never-parses", StringComparison.Ordinal)) + .ShouldBeTrue("the rejection log must carry the raw correlation id"); + } + + /// Test logger that captures all log entries. + /// The category type. + private sealed class CapturingLogger : ILogger + { + /// Gets the captured log entries. + public List<(LogLevel Level, string Message, Exception? Exception)> Entries { get; } = []; + + /// Begins a scope (stub implementation). + /// The type of the scope state. + /// The scope state. + /// A no-op scope. + public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; + + /// Checks if logging is enabled (always true). + /// The log level to check. + /// true. + public bool IsEnabled(LogLevel logLevel) => true; + + /// Logs an entry and captures it. + /// The type of the log state. + /// The log level. + /// The event ID. + /// The log state. + /// The exception, if any. + /// The log message formatter. + public void Log( + LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + { + Entries.Add((logLevel, formatter(state, exception), exception)); + } + + private sealed class NullScope : IDisposable + { + /// Gets the singleton instance. + public static NullScope Instance { get; } = new(); + + /// No-op. + public void Dispose() { } + } + } + + /// Minimal — the service reads only the method path. + private sealed class FakeServerCallContext : ServerCallContext + { + protected override string MethodCore => "/mesh_transport.v1.CentralCommandService/ReportApply"; + protected override string HostCore => "localhost"; + protected override string PeerCore => "ipv4:127.0.0.1:0"; + protected override DateTime DeadlineCore => DateTime.MaxValue; + protected override Metadata RequestHeadersCore => []; + protected override CancellationToken CancellationTokenCore => CancellationToken.None; + protected override Metadata ResponseTrailersCore { get; } = []; + protected override Status StatusCore { get; set; } + protected override WriteOptions? WriteOptionsCore { get; set; } + protected override AuthContext AuthContextCore => new(null, new Dictionary>()); + + protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options) => + throw new NotSupportedException(); + + protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask; + } +}