RFC 862 (echo) in PHP, Part 1: TCP fun

A self-contained server implementation in PHP (8.1) of RFC 862 (echo).

That old (1983) RFC describes an IP-based client-server application where the client sends data to the server, and the server returns the same data back to the client (hence the name "echo").
It used to be used for networking testing, although nowadays we can use ping (ICMP) for that. The RFC mandates that the application should work over TCP and UDP (which are the transport layers on top of the IP layer).

In this first instalment, we are going to focus on the TCP implementation only. In PHP, there is high-level set of functions stream_socket_* we can use for that.

The sequence goes like this:

  1. stream_socket_server() to create a socket to be used for the server side
  2. stream_socket_accept() to accept incoming connection on the socket above
  3. The function above has returned a resource we can call IO functions to read from (fread())
  4. and to write to (fwrite())
  5. fclose() to close the resource after we've reached EOF (end-of-file)

We will be using the generic utility netcat as the client programm.

The code

#!/usr/bin/env php
<?php

    declare(strict_types=1);

    error_reporting(E_ALL ^ E_WARNING);
    pcntl_async_signals(true);
    pcntl_signal(SIGINT, function () {
        echo Status::Stopped->value . PHP_EOL;
        exit(EXIT_OK);
    });
    const EXIT_WITH_ERROR = 1;
    const EXIT_OK = 0;

    enum Status: String
    {
        case Started = "Server has started. Press Ctrl-C to stop it.";
        case Stopped = "Server has stopped.";
        case TimeOut = "Time out, accepting connection again.";
        case Error = "Could not create a server socket. Exiting with error.";
    }

    final class Connection
    {
        /**
         * @param resource $stream
         */
        public function __construct(public readonly mixed $stream)
        {
            assert(is_resource($this->stream));
        }
    }

    interface Service
    {
        public function run(Connection $connection): void;
    }

    final class EchoService implements Service
    {
        public const READ_BUFFER = 4096;

        public function run(Connection $connection): void
        {

            while (!feof($connection->stream)) {
                $input = fread($connection->stream, self::READ_BUFFER);
                if ($input) {
                    fwrite($connection->stream, $input);
                }
            }
            fclose($connection->stream);
        }
    }

    final class TCPServer
    {
        public const TIMEOUT = 60;

        /**
         * @param resource $socket
         */
        private function __construct(public readonly mixed $socket)
        {
        }

        public static function create(string $endpoint): TCPServer
        {
            if (! $socket = stream_socket_server($endpoint)) {
                echo Status::Error->value . PHP_EOL;
                exit(EXIT_WITH_ERROR);
            }
            echo Status::Started->value . PHP_EOL;
            return new self($socket);
        }

        public static function listen(TCPServer $server, Service $service): void
        {
            while (true) {
                while ($conn = stream_socket_accept($server->socket, self::TIMEOUT)) {
                    $service->run(new Connection($conn));
                }
                echo Status::TimeOut->value . PHP_EOL;
            }
        }
    }

    $server = TCPServer::create("0.0.0.0:7");
    TCPServer::listen($server, new EchoService());

Usage

  1. launch the server
$ ./echo.php
  1. You can use netcat to interact with it on another terminal:
$ echo "hello monde" | nc 0.0.0.0 7 

There is just enough structure so I can copy reusable parts and make other toys in the same vein. Next iteration: Add support for UDP.

A limitation of this implementation at the moment is netcat doesn't always return if called interactively as it seems to wait for connection to close but the server doesn't always close. No such problem when piping in the text to echo as in my example above.
The way I work around it is to press [Ctrl-D] after I got my input back:

$ nc -v 0.0.0.0 7
Connection to 0.0.0.0 port 7 [tcp/echo] succeeded!
hello monde
hello monde
[Ctrl-D]

It works because [Ctrl+D] sends and EOF to stdin which is what the code above waits for to exit the loop:

    while (!feof($connection->stream)) {

Another annoyance (from a code reading and type security perspective) is that stream_* function signature require sockets to be resource.
Since it's not a valid type, we need to use mixed type hint and augment it with PHPDoc annotation for static analysis. To keep the domain object clean, we wrap the socket in its own class (Connection).