Part 4: Creating an echo server

Published 3 months ago Updated 3 months ago


In the previous part, we covered how to create a very bare-bones TCP server using bare sockets and even send data to the client. We're going to take this a step further by creating an echo server.

An echo server is simply a server which listens for data from the client and quite literally “echoes” it back to the client. This technique is used to verify your networking logic is sound, and that your internet connection is stable.

To start with, we do everything the same as before by creating a listener socket, calling the necessary Bind and Listen methods, and Accepting the next client which connects. However before we send any data, we first need to read data to know what exactly to echo back to the client.

The native function to read data from a socket is recv (man page, Winsock docs) - short for “receive” - and in .NET we're given the Receive method.

This method accepts a pre-allocated block of memory which serves as the destination into which the received bytes will be stored, a length to indicate how many bytes should be read, and once again another “flags” parameter. Since we already covered the flags parameter in the last part, let's explain the others.

When we receive data from a socket, the size (or len) parameter indicates the maximum amount of data we're prepared to receive. The number of bytes we receive may not be equal to the number of bytes we expect to receive, and so this function - conveniently enough - returns an integer which indicates how many bytes were read.

This is where things get a little tricky. Telnet behaves quite differently on Windows compared to Linux. On Linux, your input is buffered and only sent to the server upon hitting a new line character (i.e. when the Enter key is pressed). However on Windows, data is sent in real-time. As you type into Telnet, it sends each character individually with every keystroke that you make.

First, we'll cover a solution that will work for Linux clients. We need to allocate an array, call Receive by giving it a reference to the array as well as the length we expect to receive, and store its return value which represents the number of bytes actually received.

After that, it's simply a matter of sending data back with the Send method, with the length being that “actual” length variable.

Socket client = socket.Accept();

byte[] data = new byte[100];
<mark>int bytesRead = client.Receive(data, 100, SocketFlags.None);</mark>
client.Send(data, <mark>bytesRead</mark>, SocketFlags.None);

client.Close();

If you're on Linux, you should be able to run the server and connect with the telnet command, type your message, and have it sent back to you when you hit Enter.

Windows users, however…

Because the Windows implementation of Telnet sends data in real-time, we need to account for the fact that not all the message will arrive together. So an extremely rough workaround to this would be to loop the Receive call, 1 byte at a time, until the user hits Enter, which will send a new line character (\n, with a byte value of 0A16).

For this we'll treat bytesRead as an index so that we know where we need to insert the byte into the array, as well as an additional byte array of length 1 to serve as the Receive buffer. Once we receive the byte for the character \n, we can break out of the loop and send the data back.

byte[] data = new byte[100];
byte[] buffer = new byte[1];

int bytesRead = 0;

while(true)
{
    client.Receive(buffer, 1, SocketFlags.None);
    
    byte currentByte = buffer[0];
    data[<mark>bytesRead++</mark>] = currentByte;

    if (<mark>currentByte == '\n'</mark>)
        break;
}

client.Send(data, <mark>bytesRead</mark>, SocketFlags.None);

client.Close();

This approach will - conveniently enough - still work for Linux clients. Even though the Linux version of Telnet sends data at all once, we can still choose to read 1 byte at a time, however inefficient that may be. However, this solution is clunky and verbose and not a very clean way to do this at all. In .NET, we have a better way.

The better way

It's time to take advantage of some .NET APIs which can drastically simplify this read process. Other languages do have equivalent alternatives to this, so don't be disheartened if you're not using C# or another .NET language. You'll be able to achieve similar functionality yourself, you'll just have to look up how to do it.

The API we'll be using in .NET comes in the form of the StreamReader class and the StreamWriter class. These classes encapsulate an existing stream, and make dealing with strings much much easier. This makes more sense for our purposes right now since we're using Telnet which, by design of being a terminal app to be used by a human, deals pretty much exclusively with text.

We covered in part 3 how to wrap the socket in a stream using a NetworkStream, so we can do that now and create our StreamReader and StreamWriter afterwards:

using System.Net;
using System.Net.Sockets;

var socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
var endpoint = new IPEndPoint(IPAddress.Any, 12345);
socket.Bind(endpoint);
socket.Listen(5);

Socket client = socket.Accept();
<mark>var stream = new NetworkStream(client);</mark>
<mark>var reader = new StreamReader(stream);</mark>
<mark>var writer = new StreamWriter(stream);</mark>

client.Close();
socket.Close())

The API for both the StreamReader and StreamWriter classes should feel awfully familiar to you, as it's the same API you use when you interact with the console using Console.WriteLine and Console.ReadLine. This means all it takes to create an echo server is to call the ReadLine method on the reader, and the WriteLine method on the writer, in two very short lines:

string? line = reader.ReadLine();
writer.WriteLine(line);

One final thing we need to do is Flush the stream, as we discussed in part 3. But another way to do this would be to use the Flush method of StreamWriter instead, which does the exact same thing. So after writing the line to the client, we need one more method call:

writer.Flush();

The full code looks something like this:

using System.Net;
using System.Net.Sockets;

var socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
var endpoint = new IPEndPoint(IPAddress.Any, 12345);
socket.Bind(endpoint);
socket.Listen(5);

Socket client = socket.Accept();
var stream = new NetworkStream(client);
var reader = new StreamReader(stream);
var writer = new StreamWriter(stream);

string? line = reader.ReadLine();
writer.WriteLine(line);
writer.Flush();

client.Close();
socket.Close();

Keeping the exchange going

We've now created a very simple server which echoes back a single line of text back to the client. But we ideally want to keep things going, echoing back indefinitely, until the client finally decides it's time to leave.

Similar to Console.ReadLine, StreamReader.ReadLine returns a nullable string. This explains why the input variable is declared as string? rather than string. However the difference here is that although your code will never actually experience a null line from the console, it can and most certainly will experience a null line from a socket.

The underlying stream's Read method returns 0 when the end of the stream has been reached. (Or ReadByte which returns -1, as we discussed in part 2, because 0 is actually a valid value for a byte.) The equivalent return value from StreamReader.ReadLine is null. This null value indicates the end of the stream.

But why might it return null? Well the end of the stream is reached when the client loses connection. And actually, this is also the case when Console.ReadLine returns null too. It's just that the only way the end of the standard input stream can be reached is by the program quitting completely - at which point, your code is no longer running and will never actually see it return null.

At any rate, this loss of connection is an important event to be aware of. If a client ends their connection by closing the app/game (or simply experiences network issues) we need to perform the necessary cleanup so that we're not keeping track of them anymore. By checking if the NetworkStream's Read method returns 0, or if a wrapping StreamReader's ReadLine method returns null, we can determine when this happens.

This means in order to keep an exchange going until the client decides it's time to end things, we just need to loop until we get a null return from ReadLine. I'll also add in an actual response to say "Your message was: ", like so:

<mark>while (true)</mark>
<mark>{</mark>
    string? input = reader.ReadLine();
    <mark>if (input is null)</mark>
    <mark>{</mark>
        <mark>// the connection has been closed</mark>
        <mark>break;</mark>
    <mark>}</mark>

    writer.WriteLine($"Your message was: {input}");
    writer.Flush();
<mark>}</mark>

Run the server and connect using our trusty friend Telnet. You should be able to have an echo exchange with the server until you close the connection.

Linux users should see the line Escape character is ^], this means you can hit Ctrl + ] (right bracket) to break out of the input loop and enter the Telnet shell. At which point you can type in quit to exit out of Telnet entirely - closing the connection and terminating the server.

On Windows, the same key combination should work but it's possible it won't because ✨ Windows ✨. If it doesn't and none of the common shortcuts work either (Ctrl + C, Ctrl + X, etc.), you're probably just better off closing the command prompt window.

WSL still my beloved.

If at any point you got lost, the full code has now become:

using System.Net;
using System.Net.Sockets;

var socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
var endpoint = new IPEndPoint(IPAddress.Any, 12345);
socket.Bind(endpoint);
socket.Listen(5);

Socket client = socket.Accept();
var stream = new NetworkStream(client);
var reader = new StreamReader(stream);
var writer = new StreamWriter(stream);

while (true)
{
    string? input = reader.ReadLine();
    if (input is null)
    {
        // the connection has been closed
        break;
    }

    writer.WriteLine($"Your message was: {input}");
    writer.Flush();
}

client.Close();
socket.Close();

In the next part, we'll cover how to accept more than one client!