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(); } } }