Now that we know how all the system calls work, let’s put them together into working code. Rather than try to deal with concurrency and sockets in one go, we’ll first build a nonconcurrent server that we can test for correct implementation of the socket-handling patterns. Then, once we’ve verified its correctness, we’ll modify it to handle multiple concurrent connections.
In the next chunk of code, we’ll create the sockaddr_in struct that describes the port and address to bind to. Then, we’ll invoke socket() and bind, and check their results, before looping over accept() and handling incoming connections.
| def serve(port:UShort): Unit = { |
| // Allocate and initialize the server address |
| val addr_size = sizeof[sockaddr_in] |
| val server_address = malloc(addr_size).cast[Ptr[sockaddr_in]] |
| !server_address._1 = AF_INET.toUShort // IP Socket |
| !server_address._2 = htons(port) // port |
| !server_address._3._1 = INADDR_ANY // bind to 0.0.0.0 |
| |
| // Bind and listen on a socket |
| val sock_fd = socket(AF_INET, SOCK_STREAM, 0) |
| val server_sockaddr = server_address.cast[Ptr[sockaddr]] |
| val bind_result = bind(sock_fd, server_sockaddr, addr_size.toUInt) |
| println(s"bind returned $bind_result") |
| val listen_result = listen(sock_fd, 128) |
| println(s"listen returned $listen_result") |
| println(s"accepting connections on port $port") |
| |
| // Main accept() loop |
| while (true) { |
| val conn_fd = accept(sock_fd, null, null) |
| println(s"accept returned fd $conn_fd") |
| // we will replace handle_connection with fork_and_handle shortly |
| handle_connection(conn_fd) |
| } |
| close(sock_fd) |
| } |
We can quickly implement a simple handle_connection() function that just writes back whatever it reads, which is commonly known as an echo server. We’ll use read() and write() to actually perform our communication, and we can share the same buffer for each. When it’s time to switch to a concurrent architecture, we’ll replace handle_connection() with fork_and_handle().
We also have to decide what behavior we’d actually like to implement. For now, we’ll do something simple. When a message is received, we’ll print a simple message, read a line of text from the client, echo it back out, and have it repeat until the connection is closed:
| def handle_connection(conn_socket:Int, max_size:Int = 1024): Unit = { |
| val message = |
| c"Connection accepted! Enter a message and it will be echoed back\n" |
| |
| val prompt_write = write(conn_socket, message, strlen(message)) |
| |
| val line_buffer = malloc(max_size) |
| while (true) { |
| val bytes_read = read(conn_socket, line_buffer, max_size) |
| println(s"read $bytes_read bytes") |
| if (bytes_read == EOF) |
| // This means the connection has been closed by the client |
| return |
| val bytes_written = write(conn_socket, line_buffer, bytes_read) |
| println(s"wrote $bytes_written bytes") |
| } |
| } |
Now, let’s test it out! We can build this just like our other examples by placing sbt nativeLink in the /examples/simple-echo-server directory and executing it with ./target/simple-echo-server-build-out. When you do, you should see a prompt like this:
| :scala-native:blocking_server $ ./target/simple-echo-server-build-out |
| bind returned 0 |
| listen returned 0 |
| listening on port 8080 |
So far, so good. It looks like we’re blocking at the accept() call, but how do we test the connection-handling logic? We could write a custom client program, but we have a better option: netcat, also known as nc, a multipurpose command-line network utility, which we first used in Chapter 3, Writing a Simple HTTP Client. If you’re using our Docker-based build environment, netcat is already available—use docker exec -it scala-native-build-env to get another shell into the same container. Then we can connect and interact with the nc address port.
If the echo server is running and you’re in the same container, you should see something like this:
| $ nc localhost 8080 |
| Connection accepted! Enter a message and it will be echoed back |
| hello |
| hello |
| goodbye |
| goodbye |
Our server appears to be echoing correctly. But what happens if we open up a second connection with another new terminal session while keeping both the server and the first connection open?
| $ nc localhost 8080 |
We get nothing, just a hanging connection. To diagnose this further, let’s use nc -v for verbose output:
| $ nc -v localhost 8080 |
| Connection to localhost 8080 port [tcp/http-alt] succeeded! |
This is exactly what we would expect. Because our server’s socket is in a listening state, the OS will establish the connection for us, send an acknowledgment to the client, and put the connection onto the backlog, where it will remain until we call accept() again. But we won’t actually get to call accept() until the first connection terminates.
If we were to fill the backlog up entirely, we’d instead see this:
| $ nc -v localhost 8080 |
| nc: connect to localhost port 8080 (tcp) failed: Connection refused |
This is one of the worst failure states for a server program: dropping connections on the floor. We could use many ways to resolve this issue with varying levels of performance and complexity. For now, we’ll implement a technique that uses fork() and waitpid(), much like we did in our shell example in Chapter 4.