richardcocks

2025-05-08 My stackoverflow question was closed, so here's a blog post about CoreWCF

I'm preparing a blog post on remote procedure calls (RPC) and Interprocess Communication between .NET Framework and dotnet 8, but while doing so I ran into an issue with a service getting stuck consuming CPU after a client has closed.

It's not a good fit for asking Claude, etc., because there's too much confusion on whether you're talking about CoreWCF, WCF client on .Net Core (System.ServiceModel nuget package version ), or WCF on .NET Framework ( System.ServiceModel from the framework ). This is confusing enough for humans, let alone a machine that will happily reproduce calls and configuration from something that is almost identical, yet subtly different and incompatible.

I didn't want to raise a github issue, because I doubt I've found a bug in WCF, especially since it feels like what I'm trying to do is probably a misunderstanding of how WCF streams are supposed to work. I'm also quickly hitting maximum message lengths, and that feels like a red flag that I'm not using it as intended.

I took my question to StackOverflow (SO). I'm not a stranger to SO, although it's been a while since I have contributed anything there. This turned out to be a mistake, because I rushed asking my question. I extracted the classes I thought were pertinent to the question and that I thought could reproduce the issue. I skipped over some boilerplate starting Kestrel, but I also included a link to my repo in case answerers wanted to see the full context of the servicescalled. I had forgotten that any external links are a big no-no in SO land, so my question immediately attracted 2 close votes.

I went back, rewrote the entire question, now with a complete minimal reproduction of the issue. Client and Server fully complete in 3 source files. It now contained all the code needed to reproduce the issue, and nothing but that code.

Two days later my question got it's third vote for closure, and remains unanswered and now closed forever.

In my frustration I'm writing this blog post, to briefly introduce CoreWCF and hope someone will be able to answer my question about what I'm doing wrong.

Scenario

For the purpose of testing RPC throughput, I want to stream random numbers from one process to another. We can test requesting numbers call-by-call or streamed. Ideally with sequence numbers so we can also examine the reliabilty for certain transport types.

In gRPC, the proto file would look something like this:

syntax = "proto3";

option csharp_namespace = "RandomNumberGrpc";

package randomService;

service RandomProvider {
  rpc NextInt (NextIntRequest) returns (ValueWithSequence);
  rpc Stream (NextIntStreamRequest) returns (stream ValueWithSequence);
}

message NextIntRequest {}
message NextIntStreamRequest{}

message ValueWithSequence {
  int32 sequenceNumber = 1;
  int32 Value = 2;
}

In WCF, we define services through ServiceContract attributes. For this post I'll just focus on the streaming service and ignore the per-call implementation. In WCF, as I understand it, we can't strongly type our stream. For now therefore, to get streams working, I just made a contract that returns a raw byte Stream, and will worry about casting or marshalling that to the right structure later.

Let's look at our service class.

namespace RandomNumberCore;

[ServiceContract]
public interface IStreamingService
{
    [OperationContract]
    Stream GetRandomStream();
}
    

public class StreamingService : IStreamingService
{
    public Stream GetRandomStream()
    {
        return new RandomStream(Random.Shared);
    }
}

Where RandomStream is my own class that exposes Random.Shared as a stream, without worrying about returning the sequence number for now:

namespace RandomNumberCore;

public class RandomStream : Stream
{
    public RandomStream(Random random)
    {
        this._random = random;
    }
    
    private int _sequence;
    private readonly Random _random;
    public override bool CanRead => true;

    public override bool CanSeek => false;

    public override bool CanWrite => false;

    public override long Length => throw new NotSupportedException();

    // ReSharper disable once ValueParameterNotUsed
    public override long Position { get => _sequence; set => throw new NotSupportedException(); }

    public override void Flush()
    {}
    
    public override int Read(byte[] buffer, int offset, int count)
    {
        var internalBuffer = new Span<byte>(buffer, offset, count);
        _random.NextBytes(internalBuffer);
        _sequence+=count;
        return count;
    }

    public override int Read(Span<byte> buffer)
    {
        _random.NextBytes(buffer);
        _sequence+=buffer.Length;
        return buffer.Length;
    }

    public override long Seek(long offset, SeekOrigin origin)
    {
        throw new NotSupportedException();
    }

    public override void SetLength(long value)
    {
        throw new NotSupportedException();
    }

    public override void Write(byte[] buffer, int offset, int count)
    {
        throw new NotSupportedException();
    }
}

A read-only, non-seekable stream that returns random numbers. I've not extensively tested this stream, but it appears to work fine.

Let's write a simple client, including the contract. In a real world application you'd likely define a contract assembly to share the contracts.

using System.ServiceModel;

namespace RandomNumberConsumerNet8
{
    [ServiceContract]
    public interface IStreamingService
    {
        [OperationContract]
        Stream GetRandomStream();
    }
    public interface IStreamingServiceChannel : IStreamingService, IClientChannel;

    internal class Program
    {
        public static async Task Main(string[] args)
        {
            var cts = new CancellationTokenSource();
            using var channelFactory = new ChannelFactory<IStreamingServiceChannel>(new BasicHttpBinding(BasicHttpSecurityMode.Transport){TransferMode = TransferMode.Streamed, MaxReceivedMessageSize = 1_000_000_000 }, new EndpointAddress("https://localhost:7151/StreamingService.svc"));
            using var service = channelFactory.CreateChannel();
            service.Open();
            using var randomStream = service.GetRandomStream();
            byte[] buffer = new byte[4];
            await randomStream.ReadExactlyAsync(buffer, cts.Token);
            
            Console.WriteLine($"Received bytes {buffer[0]} , {buffer[1]}, {buffer[2]}, {buffer[3]} ");
            service.Close();
            channelFactory.Close();
        }
    }
}

Results

Now when I run the server and client, it appears to work:

Received bytes 101 , 18, 99, 251

Great, we opened the stream, streamed 4 bytes and then closed service.

But now when I look at my CPU, it's still chugging along. Profiling the server shows it's still trying to write bytes to the stream, well after the client has long ago disconnected.

Server trace

What is it doing? It's trying to write to the stream, with no hint of back-pressure or sense it shouldn't be doing so. The sending stream doesn't have any sense that it's not being read from.

I'm coming to the conclusion that WCF streaming is not suitable for this, and is only suitable for single bounded streams, not for streams of unknown length or a stream of messages.

But it's not therefore clear what to do in this scenario, of wanting to transfer an unknown quantity of random numbers. Do I go back to requesting numbers via single messages? That has limited throughput. My initial testing showed ~5k messages / sec that way. I could manually increase the buffer so that each message sends a greater quantity of random numbers, but that loses the fidelity, and rather misses the point, this isn't really about random numbers, it's about how quickly we can pass messages between applications.

Do I need to use session mode and coordinate the stream externally to the RPC?

I'm not sure if I've missed the point of WCF streams or something else about WCF tuning entirely, but what I really wish is that this could have been answered on StackOverflow, so others trying something similarly misguided could have learned from my mistakes.

Footnote

Comments? Questions? Want to point out how much of an idiot I've been? Please reach out at @eterm.bsky.social.