[Main] [Book Recs] [Talks] [Sponsor]

Network Programming with Hare

I've been reading Drew Devault's blog for quite some time. Recently, he and a small team released a new programming language called Hare that is laser focused on systems programming and meant to compete with C, Rust, Zig, but have some of the simple design choices of Go. The biggest differences from Go I can see is Hare's lack of a garbage collector so memory management like C, Rust, and Zig is completely up to you, the programmer and lack of built-in concurrency support.

It has a fairly small standard library with some additional batteries included for doing a lot of common tasks and so I wanted to see what it could do when compared to Go specifically since that is my main programming language these days. I like to create simple UDP and TCP servers in new languages so see how they stack up against Go in terms of readability and conciseness.

Features of each server include:

UDP Server

Since UDP is connectionless the server is pretty straightforward and I don't have to do any multiplexing of client connections. The server just sets up a listener and waits for incoming data, logs it, and writes it back to the client. TCP, however, is much more complicated to handle multiple simultaneous connections.

use fmt;
use net::udp;
use net::ip;
use strings;

export fn main() void = {
    // create a udp listener on the local v4 ip and a port
    const listener = udp::listen(ip::LOCAL_V4, 8000)!;

    for (true) {
        // define buffer to hold incoming data
        let buf: [1024]u8 = [0...];

        // define ip address to hold source of data
        let src: ip::addr = ip::ANY_V4;

        // define port to hold source's port of data
        let port: u16 = 0;

        // block and wait for incoming data on the listener
        // and write the buf, src, port accordingly
        let n = udp::recvfrom(listener, &buf, &src, &port)!;

        // re-slice the buffer to the length of the
        // data that came in
        let buf = buf[..n];

        // print out the variables and format the buffer
        // as a string
        fmt::printfln("{}:{} says {}", ip::string(src), port, strings::fromutf8(buf))!;

        // echo it back to the client
        udp::sendto(listener, buf, src, port)!;
    };
};

TCP Server

The same basic idea is the same as UDP, but in order to handle multiple simultaneous I need to store and hold onto those and periodically check when data is incoming from them. There is a lot of bookkeeping of these connections, but Hare does support poll which allows us to check for activity on those connections.

I set up a listener as before, but then I add it to a list of file descriptors inside poll and start checking for events on all of them. If an event comes in from the listener file descriptor I know it is a new client connection so I add it to the file descriptors for poll to watch. If an event happens on one of the client file descriptors I know it is data incoming so I can read from it, log it, and then write it back to the client. The biggest differences from Go I can see is Hare's lack of a garbage collector so memory management like C, Rust, and Zig is completely up to you, the programmer and lack of built-in concurrency support.

use net::tcp;
use net::ip;
use strings;
use unix::poll;
use io;

def MAX_CLIENTS: u8 = 10;

export fn main() void = {
    // create a udp listener on the local v4 ip and a port
    const listener = tcp::listen(ip::LOCAL_V4, 8000)!;

    // create a bunch of poll file descriptors and set the first one to the
    // listener created above
    let fds: [MAX_CLIENTS]poll::pollfd = [
        poll::pollfd {
            fd = listener,
            events = (poll::event::POLLIN | poll::event::POLLPRI),
            ...
        }
        ...
    ];

    // fill the rest of the polling file descriptors with empty ones for
    // clients that want to connect
    for (let i: size = 1; i < MAX_CLIENTS; i+=1) {
        fds[i] = poll::pollfd {
            fd = 0,
            events = 0,
            ...
        };
    };

    for (true) {
        // poll all of the file descriptors and block until there is
        // activity on one of them
        poll::poll(fds, poll::INDEF)!;

        // once we get activity check if it was from the first one which
        // is the listener which means we have a new incoming client connection
        if (fds[0].revents == poll::event::POLLIN) {
            // accept the client connection from the listener
            let conn = net::accept(listener)!;

            log::println("client conn accepted");

            // loop over the remaining file descriptors and find and empty
            // slot for the client and set the appropriate events to poll for
            for (let i: size = 1; i < MAX_CLIENTS; i+=1) {
                if (fds[i].events == 0) {
                    log::printfln("client conn added to position {}", i);

                    fds[i].fd = conn;
                    fds[i].events = (poll::event::POLLIN | poll::event::POLLPRI);
                    
                    break;
                };
            };
        };

        // lastly loop over all the client file descriptors to check if any
        // of them have written data
        for (let i: size = 1; i < MAX_CLIENTS; i+=1) {
            if (fds[i].events > 0 && (fds[i].revents == poll::event::POLLIN || fds[i].revents == poll::event::POLLPRI)) {
                // set up a read/write buffer with the client's file
                // descriptor
                let rbuf: [1024]u8 = [0...];
                let wbuf: [1024]u8 = [0...];
                let buffered = bufio::buffered(fds[i].fd, rbuf, wbuf);

                // attempt to read the line
                match (bufio::scanline(&buffered)) {
                
                // if a line is read then grab the client's ip/port and log out
                // the data they sent and also echo it back to the client
                case let line: []u8 =>
                    let peer: (ip::addr, u16) = tcp::peeraddr(fds[i].fd) as (ip::addr, u16);
                    fmt::printfln("{}:{} says {}", ip::string(peer.0), peer.1, strings::fromutf8(line))!;

                    io::writeall(&buffered, line)!;
                    bufio::flush(&buffered)!;

                // if reading the line returns and EOF then we can assume the
                // client disconnected so we close the file descriptor and reset
                // the events to open up a slot for a new client to connect to
                case io::EOF =>
                    io::close(fds[i].fd)!;
                    fds[i].events = 0;
                    fds[i].revents = 0;

                // any other error we reset the events for a new client slot
                case let err: io::error =>
                    fds[i].events = 0;
                    fds[i].revents = 0;
                };
            };
        };
    };

    // shut down the listener if we ever get here
    net::shutdown(listener);
};

Thoughts

Overall, I like Hare. It writes like Go since a lot of it was inspired by Go, but it's target audience is C, Rust, and Zig programmers who are doing more low level systems programming like sockets, device drivers, and kernels.

The standard library is pretty complete with all the tools you need to do sorting, crypto, basic encoding, working with files, etc. Some of the extended library packages will get SQL/database support, additional encoding, but they might just stop there. While you could write a REST HTTP API with Hare, it would take a monumental effort to support the HTTP protocol so if this is what you're thinking of doing I would just use Go instead. However, if CPU and safe memory matter then Hare might be a good fit if the other systems programming languages are too unsafe (C) or complex (Rust/Zig) and you need a simple language with fast compile times and good tooling like Go.