From 23d6f083557d9045799e6c7f32d693ca8c5db910 Mon Sep 17 00:00:00 2001 From: "Ahmed Afifi (iMetaverse LLC)" Date: Fri, 24 Jul 2026 06:47:43 +0300 Subject: [PATCH] Fix ServiceChannel auto-close when server initiates session shutdown (#5803) When a server closes its duplex session while the client is idle between calls, the client's receive pump processes the EndRecord and calls ServiceChannel.DecrementActivity. Previously that path only half-closed the inner session; the outer ServiceChannel stayed in Opened, the Closed event never fired, and subsequent user calls silently reused a torn-down session. Product fix in src/System.ServiceModel.Primitives/.../ServiceChannel.cs: - Rewrite DecrementActivity's client-only auto-close path to send our EndRecord (Session.CloseOutputSession), then hop off the receive-pump thread via ActionItem.Schedule to complete the outer Close. Closing inline can deadlock against the SynchronizedMessageSource semaphore that OnClose -> EnsureInputClosedAsync re-acquires. - New CompleteAutoClose helper drives Opened -> Closing -> Closed, and Aborts on Communication/Timeout/InvalidOperation failures so the Closed/Faulted event still fires. - If our half-close itself fails, Abort the channel so subscribers see a state change instead of a stale Opened. Regression test (skipped under the CoreWCF host, see below): - New ServerInitiatedSessionShutdownTests.ServerInitiatedShutdown_ ClientChannelTransitionsToClosed opens a duplex NetTcpBinding session, warms the receive pump with an Echo, asks the server to shut down the session, and asserts the client transitions through Closing to Closed within the binding's CloseTimeout. - Server-side helper (IServerInitiatedShutdownService + service impl in WcfDuplexService.cs) captures the current IClientChannel via a CaptureChannelServiceBehavior IDispatchMessageInspector, then closes the session's output side after replying. - CaptureChannelServiceBehavior is registered from ApplyConfiguration, not the ctor: on the CoreWCF host shim, ServiceHost.Description returns a throwaway ServiceDescription until ApplyConfig wires up the real ServiceHostBase, so a ctor-time Behaviors.Add silently no-ops. - Test gated with [Condition(nameof(Skip_CoreWCFService_FailedTest))]: no public CoreWCF API exposes the underlying ISessionChannel< IDuplexSession> for the current operation, so the server-side helper can't close the session on the CoreWCF host leg. The client-side product fix is still exercised on all client TFMs. Co-authored-by: Matt Connew Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Copilot-Session: be4b665b-dccf-459f-9605-ef6e089a5ba9 --- .../tests/Common/Scenarios/Endpoints.cs | 5 + .../Common/Scenarios/ServiceInterfaces.cs | 18 +++ .../ServerInitiatedSessionShutdownTests.cs | 93 ++++++++++++++ .../App_code/IWcfDuplexService.cs | 19 +++ .../App_code/WcfDuplexService.cs | 113 ++++++++++++++++++ .../testhosts/DuplexTestServiceHosts.cs | 27 +++++ .../ServiceModel/Channels/ServiceChannel.cs | 110 +++++++++++++---- 7 files changed, 363 insertions(+), 22 deletions(-) create mode 100644 src/System.Private.ServiceModel/tests/Scenarios/Client/ChannelLayer/ServerInitiatedSessionShutdownTests.cs diff --git a/src/System.Private.ServiceModel/tests/Common/Scenarios/Endpoints.cs b/src/System.Private.ServiceModel/tests/Common/Scenarios/Endpoints.cs index 11e2d890e87..ac817f695ee 100644 --- a/src/System.Private.ServiceModel/tests/Common/Scenarios/Endpoints.cs +++ b/src/System.Private.ServiceModel/tests/Common/Scenarios/Endpoints.cs @@ -645,6 +645,11 @@ public static string Tcp_NoSecurity_XmlDuplexCallback_Address get { return GetEndpointAddress("DuplexCallbackXmlComplexType.svc/tcp-nosecurity-callback", protocol: "net.tcp"); } } + public static string Tcp_NoSecurity_ServerInitiatedShutdown_Address + { + get { return GetEndpointAddress("ServerInitiatedShutdown.svc/tcp-nosecurity-server-shutdown", protocol: "net.tcp"); } + } + public static string Tcp_CustomBinding_SslStreamSecurity_Address { diff --git a/src/System.Private.ServiceModel/tests/Common/Scenarios/ServiceInterfaces.cs b/src/System.Private.ServiceModel/tests/Common/Scenarios/ServiceInterfaces.cs index 0dbc96b423a..dc8687f9a1e 100644 --- a/src/System.Private.ServiceModel/tests/Common/Scenarios/ServiceInterfaces.cs +++ b/src/System.Private.ServiceModel/tests/Common/Scenarios/ServiceInterfaces.cs @@ -418,6 +418,24 @@ public interface IWcfDuplexService_Xml_Callback void OnXmlPingCallback(XmlCompositeTypeDuplexCallbackOnly xmlCompositeType); } +// Client-side mirror of WcfService.IServerInitiatedShutdownService used by the +// ServerInitiatedSessionShutdownTests scenario (dotnet/wcf#5803 regression). +[ServiceContract(SessionMode = SessionMode.Required, CallbackContract = typeof(IServerInitiatedShutdownCallback))] +public interface IServerInitiatedShutdownService +{ + [OperationContract] + string Echo(string text); + + [OperationContract] + string RequestServerShutdown(); +} + +public interface IServerInitiatedShutdownCallback +{ + [OperationContract(IsOneWay = true)] + void OnShutdownNotification(); +} + [ServiceContract(CallbackContract = typeof(IWcfDuplexService_CallbackConcurrencyMode_Callback))] public interface IWcfDuplexService_CallbackConcurrencyMode { diff --git a/src/System.Private.ServiceModel/tests/Scenarios/Client/ChannelLayer/ServerInitiatedSessionShutdownTests.cs b/src/System.Private.ServiceModel/tests/Scenarios/Client/ChannelLayer/ServerInitiatedSessionShutdownTests.cs new file mode 100644 index 00000000000..ddd0eb84746 --- /dev/null +++ b/src/System.Private.ServiceModel/tests/Scenarios/Client/ChannelLayer/ServerInitiatedSessionShutdownTests.cs @@ -0,0 +1,93 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.ServiceModel; +using System.ServiceModel.Channels; +using System.Threading; +using Infrastructure.Common; +using Xunit; + +// Regression coverage for dotnet/wcf#5803. +// +// When a server closes its session while the client is idle between calls, +// the client's duplex receive pump processes the EndRecord and the +// ServiceChannel's auto-close path must transition the outer channel from +// Opened to Closed. Prior to the fix, only the inner session was half-closed +// and the outer channel remained Opened indefinitely. +public class ServerInitiatedSessionShutdownTests : ConditionalWcfTest +{ + [CallbackBehavior(UseSynchronizationContext = false)] + private class NoOpCallback : IServerInitiatedShutdownCallback + { + public void OnShutdownNotification() { } + } + + [WcfFact] + [Condition(nameof(Skip_CoreWCFService_FailedTest))] + [OuterLoop] + public static void ServerInitiatedShutdown_ClientChannelTransitionsToClosed() + { + DuplexChannelFactory factory = null; + IServerInitiatedShutdownService proxy = null; + ICommunicationObject commObj = null; + ManualResetEventSlim closingSignal = new ManualResetEventSlim(false); + ManualResetEventSlim closedSignal = new ManualResetEventSlim(false); + bool faulted = false; + + try + { + // *** SETUP *** \\ + NetTcpBinding binding = new NetTcpBinding(SecurityMode.None); + binding.CloseTimeout = TimeSpan.FromSeconds(10); + binding.OpenTimeout = TimeSpan.FromSeconds(10); + binding.SendTimeout = TimeSpan.FromSeconds(10); + + InstanceContext context = new InstanceContext(new NoOpCallback()); + factory = new DuplexChannelFactory( + context, + binding, + new EndpointAddress(Endpoints.Tcp_NoSecurity_ServerInitiatedShutdown_Address)); + + proxy = factory.CreateChannel(); + commObj = (ICommunicationObject)proxy; + commObj.Closing += (s, e) => closingSignal.Set(); + commObj.Closed += (s, e) => closedSignal.Set(); + commObj.Faulted += (s, e) => { faulted = true; closedSignal.Set(); }; + commObj.Open(); + + // *** EXECUTE *** \\ + // Warm-up call so the duplex receive pump is active. + string echoed = proxy.Echo("hello"); + Assert.Equal("hello", echoed); + + // Ask the service to close its session after replying. + string shutdownReply = proxy.RequestServerShutdown(); + Assert.Equal("Server shutting down", shutdownReply); + + // *** VALIDATE *** \\ + // Wait up to 10s for the client to react to the server's EndRecord. + bool signaled = closedSignal.Wait(binding.CloseTimeout + TimeSpan.FromSeconds(5)); + + Assert.True(signaled, + $"Client ServiceChannel did not transition out of {CommunicationState.Opened} after the " + + $"server closed its session. Current state: {commObj.State}. " + + "This indicates issue dotnet/wcf#5803 has regressed."); + + Assert.True(closingSignal.IsSet, + "Client ServiceChannel did not raise Closing in response to a graceful server-initiated session shutdown."); + + Assert.False(faulted, + "Client ServiceChannel transitioned to Faulted instead of Closed in response to a graceful " + + "server-initiated session shutdown."); + + Assert.Equal(CommunicationState.Closed, commObj.State); + } + finally + { + // *** ENSURE CLEANUP *** \\ + ScenarioTestHelpers.CloseCommunicationObjects((ICommunicationObject)proxy, factory); + } + } +} diff --git a/src/System.Private.ServiceModel/tools/IISHostedWcfService/App_code/IWcfDuplexService.cs b/src/System.Private.ServiceModel/tools/IISHostedWcfService/App_code/IWcfDuplexService.cs index dffa70c474c..33a9f3d884f 100644 --- a/src/System.Private.ServiceModel/tools/IISHostedWcfService/App_code/IWcfDuplexService.cs +++ b/src/System.Private.ServiceModel/tools/IISHostedWcfService/App_code/IWcfDuplexService.cs @@ -38,6 +38,25 @@ public interface IDuplexChannelCallback void OnPingCallback(Guid guid); } + // Contract used by ServerInitiatedSessionShutdownTests (dotnet/wcf#5803). + // The server replies to RequestServerShutdown and then closes its session channel, + // which delivers an EndRecord to the client and exercises ServiceChannel.DecrementActivity. + [ServiceContract(SessionMode = SessionMode.Required, CallbackContract = typeof(IServerInitiatedShutdownCallback))] + public interface IServerInitiatedShutdownService + { + [OperationContract] + string Echo(string text); + + [OperationContract] + string RequestServerShutdown(); + } + + public interface IServerInitiatedShutdownCallback + { + [OperationContract(IsOneWay = true)] + void OnShutdownNotification(); + } + [ServiceContract(CallbackContract = typeof(IWcfDuplexTaskReturnCallback))] public interface IWcfDuplexTaskReturnService { diff --git a/src/System.Private.ServiceModel/tools/IISHostedWcfService/App_code/WcfDuplexService.cs b/src/System.Private.ServiceModel/tools/IISHostedWcfService/App_code/WcfDuplexService.cs index 34258a29ecb..ea814051fa7 100644 --- a/src/System.Private.ServiceModel/tools/IISHostedWcfService/App_code/WcfDuplexService.cs +++ b/src/System.Private.ServiceModel/tools/IISHostedWcfService/App_code/WcfDuplexService.cs @@ -4,10 +4,20 @@ #if NET using CoreWCF; +using CoreWCF.Channels; +using CoreWCF.Description; +using CoreWCF.Dispatcher; +using System; +using System.Collections.ObjectModel; +using System.Threading.Tasks; #else using System; +using System.Collections.ObjectModel; using System.IO; using System.ServiceModel; +using System.ServiceModel.Channels; +using System.ServiceModel.Description; +using System.ServiceModel.Dispatcher; using System.Threading.Tasks; #endif @@ -49,6 +59,109 @@ public void Ping_Xml(Guid guid) } } + // Service implementation for ServerInitiatedShutdownService (dotnet/wcf#5803 regression coverage). + // On RequestServerShutdown the service gracefully closes the *current session's* output channel + // (sends the NetFraming EndRecord) so the idle duplex client's receive pump observes end-of-stream + // and runs DecrementActivity. This reproduces the user-reported scenario where a host shuts down + // while a session-ful client is idle, without disturbing other sessions on the same host. + [ServiceBehavior(InstanceContextMode = InstanceContextMode.PerSession, AddressFilterMode = AddressFilterMode.Any)] + public class ServerInitiatedShutdownService : IServerInitiatedShutdownService + { + public string Echo(string text) + { + return text; + } + + public string RequestServerShutdown() + { + IClientChannel channel = CaptureChannelServiceBehavior.GetCurrentChannel(); + Task.Run(async () => + { + await Task.Delay(250); + ISessionChannel duplex = channel as ISessionChannel; + try + { + if (duplex != null) + { +#if NET + await duplex.Session.CloseOutputSessionAsync(); +#else + duplex.Session.CloseOutputSession(); +#endif + } + else if (channel != null) + { + // The inspector hands us a typed callback proxy that doesn't expose + // ISessionChannel; closing the proxy tears down the + // underlying session and sends the framing EndRecord to the client. +#if NET + await ((ICommunicationObject)channel).CloseAsync(); +#else + ((ICommunicationObject)channel).Close(); +#endif + } + } + catch + { + try { if (channel != null) channel.Abort(); } catch { } + } + }); + return "Server shutting down"; + } + } + + // Captures the per-session IClientChannel into OperationContext.Extensions so the service + // operation can act on it (e.g., to gracefully close that session's output channel). + // No portable API on CoreWCF exposes the underlying ISessionChannel from + // OperationContext, so an IDispatchMessageInspector is the simplest cross-framework hook. + internal sealed class CaptureChannelServiceBehavior : IServiceBehavior, IDispatchMessageInspector + { + public void AddBindingParameters(ServiceDescription serviceDescription, ServiceHostBase serviceHostBase, + Collection endpoints, BindingParameterCollection bindingParameters) { } + + public void Validate(ServiceDescription serviceDescription, ServiceHostBase serviceHostBase) { } + + public void ApplyDispatchBehavior(ServiceDescription serviceDescription, ServiceHostBase serviceHostBase) + { + foreach (ChannelDispatcher cd in serviceHostBase.ChannelDispatchers) + { + foreach (EndpointDispatcher ed in cd.Endpoints) + { + ed.DispatchRuntime.MessageInspectors.Add(this); + } + } + } + + public object AfterReceiveRequest(ref Message request, IClientChannel channel, InstanceContext instanceContext) + { + OperationContext ctx = OperationContext.Current; + if (ctx != null && ctx.Extensions.Find() == null) + { + ctx.Extensions.Add(new ChannelHolder(channel)); + } + return null; + } + + public void BeforeSendReply(ref Message reply, object correlationState) { } + + public static IClientChannel GetCurrentChannel() + { + OperationContext ctx = OperationContext.Current; + if (ctx == null) return null; + ChannelHolder holder = ctx.Extensions.Find(); + return holder == null ? null : holder.Channel; + } + + private sealed class ChannelHolder : IExtension + { + private readonly IClientChannel _channel; + public IClientChannel Channel { get { return _channel; } } + public ChannelHolder(IClientChannel channel) { _channel = channel; } + public void Attach(OperationContext owner) { } + public void Detach(OperationContext owner) { } + } + } + [ServiceBehavior(InstanceContextMode = InstanceContextMode.PerCall, AddressFilterMode = AddressFilterMode.Any)] public class DuplexCallbackService : IDuplexChannelService { diff --git a/src/System.Private.ServiceModel/tools/IISHostedWcfService/App_code/testhosts/DuplexTestServiceHosts.cs b/src/System.Private.ServiceModel/tools/IISHostedWcfService/App_code/testhosts/DuplexTestServiceHosts.cs index 26cdfcae321..f7db332230a 100644 --- a/src/System.Private.ServiceModel/tools/IISHostedWcfService/App_code/testhosts/DuplexTestServiceHosts.cs +++ b/src/System.Private.ServiceModel/tools/IISHostedWcfService/App_code/testhosts/DuplexTestServiceHosts.cs @@ -171,4 +171,31 @@ public DuplexCallbackErrorHandlerServiceHost(params Uri[] baseAddresses) { } } + + // Host for ServerInitiatedShutdownService used by dotnet/wcf#5803 regression test. + [TestServiceDefinition(Schema = ServiceSchema.NETTCP, BasePath = "ServerInitiatedShutdown.svc")] + public class ServerInitiatedShutdownServiceHost : TestServiceHostBase + { + protected override string Address { get { return "tcp-nosecurity-server-shutdown"; } } + + protected override Binding GetBinding() + { + return new NetTcpBinding(SecurityMode.None); + } + + public ServerInitiatedShutdownServiceHost(params Uri[] baseAddresses) + : base(typeof(ServerInitiatedShutdownService), baseAddresses) + { + } + + // Register CaptureChannelServiceBehavior here rather than in the constructor. + // On the CoreWCF host shim, ServiceHost.Description returns a throwaway + // ServiceDescription until ApplyConfig sets the underlying ServiceHostBase, so a + // constructor-time Behaviors.Add silently no-ops on that path (dotnet/wcf#5803). + protected override void ApplyConfiguration() + { + base.ApplyConfiguration(); + this.Description.Behaviors.Add(new CaptureChannelServiceBehavior()); + } + } } diff --git a/src/System.ServiceModel.Primitives/src/System/ServiceModel/Channels/ServiceChannel.cs b/src/System.ServiceModel.Primitives/src/System/ServiceModel/Channels/ServiceChannel.cs index b5eb4a3f110..639be8c08e8 100644 --- a/src/System.ServiceModel.Primitives/src/System/ServiceModel/Channels/ServiceChannel.cs +++ b/src/System.ServiceModel.Primitives/src/System/ServiceModel/Channels/ServiceChannel.cs @@ -828,30 +828,18 @@ internal void DecrementActivity() throw Fx.AssertAndThrowFatal("ServiceChannel.DecrementActivity: (updatedActivityCount >= 0)"); } - if (updatedActivityCount == 0 && _autoClose) + if (updatedActivityCount != 0 || !_autoClose || State != CommunicationState.Opened) + { + return; + } + + if (!IsClient) { try { - if (State == CommunicationState.Opened) - { - if (IsClient) - { - ISessionChannel duplexSessionChannel = InnerChannel as ISessionChannel; - if (duplexSessionChannel != null) - { - _hasChannelStartedAutoClosing = true; - duplexSessionChannel.Session.CloseOutputSession(CloseTimeout); - } - } - else - { - Close(CloseTimeout); - } - } - } - catch (CommunicationException) - { + Close(CloseTimeout); } + catch (CommunicationException) { } catch (TimeoutException e) { if (WcfEventSource.Instance.CloseTimeoutIsEnabled()) @@ -859,11 +847,89 @@ internal void DecrementActivity() WcfEventSource.Instance.CloseTimeout(e.Message); } } - catch (ObjectDisposedException) + catch (ObjectDisposedException) { } + catch (InvalidOperationException) { } + return; + } + + ISessionChannel duplexSessionChannel = InnerChannel as ISessionChannel; + if (duplexSessionChannel == null) + { + return; + } + + // Send our EndRecord (half-close the output session). This is non-blocking with + // respect to the receive pump and tells the peer we won't send more application + // messages. The outer ServiceChannel must still be transitioned through + // Closing -> Closed for the Closed event to fire (dotnet/wcf#5803). + bool endRecordSent = false; + try + { + _hasChannelStartedAutoClosing = true; + duplexSessionChannel.Session.CloseOutputSession(CloseTimeout); + endRecordSent = true; + } + catch (CommunicationException) { } + catch (TimeoutException e) + { + if (WcfEventSource.Instance.CloseTimeoutIsEnabled()) + { + WcfEventSource.Instance.CloseTimeout(e.Message); + } + } + catch (ObjectDisposedException) + { + // Channel has already been disposed; no further action needed. + return; + } + catch (InvalidOperationException) { } + + if (endRecordSent) + { + // Dispatch the outer Close off the receive pump thread; closing inline can + // deadlock against the SynchronizedMessageSource semaphore which OnClose -> + // EnsureInputClosedAsync re-acquires. + ActionItem.Schedule(s_completeAutoCloseCallback, this); + } + else if (State == CommunicationState.Opened) + { + // Half-close failed; the session is unusable. Transition out of Opened so + // subscribers are notified and the next user call doesn't observe a stale state. + Abort(); + } + } + + private static readonly Action s_completeAutoCloseCallback = state => ((ServiceChannel)state).CompleteAutoClose(); + + private void CompleteAutoClose() + { + try + { + if (State == CommunicationState.Opened) + { + Close(CloseTimeout); + } + } + catch (CommunicationException) + { + Abort(); + } + catch (TimeoutException e) + { + if (WcfEventSource.Instance.CloseTimeoutIsEnabled()) { + WcfEventSource.Instance.CloseTimeout(e.Message); } - catch (InvalidOperationException) + Abort(); + } + catch (ObjectDisposedException) + { + } + catch (InvalidOperationException) + { + if (State == CommunicationState.Opened) { + Abort(); } } }