CS 3410: RPC Chat System

Introduction

In this assignment you will write a simple chat server and client that communicate via Remote Procedure Calls (RPCs). You will implement data serialization and networking from scratch.

The client and server will exist together in a single binary that will act in either role depending on the command-line arguments it is given:

./rpcchat 3410

(this launches the server and has it listen on port 3410). Or

./rpcchat krypton.cs.utahtech.edu:3410 alice

(this connects to krypton.cs.utahtech.edu:3410 and logs in as user “alice”). Or

./rpcchat :3410 bob

(this connects to localhost on port 3410 and logs in as user “bob”).

When you log in through the client you can type various commands:

In addition to responding to your commands, the client will fetch messages being sent to you and display them on the terminal.

You can download and test a working binary in Linux:

curl -sO https://computing.utahtech.edu/cs/3410/rpcchat
chmod 755 rpcchat

The server

The server sits and waits for incoming RPC requests. It keeps a queue of messages for each user currently logged in, delivering them to the client when the client requests them.

It provides the following functions over RPC to clients:

The server maintains a queue of messages for every user currently logged in. A user queue is emptied after a CheckMessages transaction when all of the messages have been returned to that user.

The core server functionality can be implemented with the following:

const (
        MsgRegister = iota
        MsgList
        MsgCheckMessages
        MsgTell
        MsgSay
        MsgQuit
        MsgShutdown
)

var mutex sync.Mutex
var messages map[string][]string
var shutdown chan struct{}

func server(listenAddress string) {
        shutdown = make(chan struct{})
        messages = make(map[string][]string)

        // set up network listen and accept loop here
        // to receive RPC requests and dispatch each
        // in its own goroutine
        // ...

        // wait for a shutdown request
        <-shutdown
        time.Sleep(100 * time.Millisecond)
}

func dispatch(conn net.Conn) {
        // handle a single incomming request:
        // 1. Read the length (uint16)
        // 2. Read the entire message into a []byte
        // 3. From the message, parse the message type (uint16)
        // 4. Call the appropriate server stub, giving it the
        //    remainder of the request []byte and collecting
        //    the response []byte
        // 5. Write the message length (uint16)
        // 6. Write the message []byte
        // 7. Close the connection
        //
        // On any error, be sure to close the connection, log a
        // message, and return (a request error should not kill
        // the entire server)
}

func Register(user string) error {
        if len(user) < 1 || len(user) > 20 {
                return fmt.Errorf("Register: user must be between 1 and 20 letters")
        }
        for _, r := range user {
                if !unicode.IsLetter(r) && !unicode.IsDigit(r) {
                        return fmt.Errorf("Register: user must only contain letters and digits")
                }
        }
        mutex.Lock()
        defer mutex.Unlock()

        msg := fmt.Sprintf("*** %s has logged in", user)
        log.Printf(msg)
        for target, queue := range messages {
                messages[target] = append(queue, msg)
        }
        messages[user] = nil

        return nil
}

func List() []string {
        mutex.Lock()
        defer mutex.Unlock()

        var users []string
        for target := range messages {
                users = append(users, target)
        }
        sort.Strings(users)

        return users
}

func CheckMessages(user string) []string {
        mutex.Lock()
        defer mutex.Unlock()

        if queue, present := messages[user]; present {
                messages[user] = nil
                return queue
        } else {
                return []string{"*** You are not logged in, " + user}
        }
}

func Tell(user, target, message string) {
        mutex.Lock()
        defer mutex.Unlock()

        msg := fmt.Sprintf("%s tells you %s", user, message)
        if queue, present := messages[target]; present {
                messages[target] = append(queue, msg)
        } else if queue, present := messages[user]; present {
                messages[user] = append(queue, "*** No such user: "+target)
        }
}

func Say(user, message string) {
        mutex.Lock()
        defer mutex.Unlock()

        msg := fmt.Sprintf("%s says %s", user, message)
        for target, queue := range messages {
                messages[target] = append(queue, msg)
        }
}

func Quit(user string) {
        mutex.Lock()
        defer mutex.Unlock()

        msg := fmt.Sprintf("*** %s has logged out", user)
        log.Print(msg)
        for target, queue := range messages {
                messages[target] = append(queue, msg)
        }
        delete(messages, user)
}

func Shutdown() {
        shutdown <- struct{}{}
}

The server functionality is exposed as a series of ordinary functions. Your job is to make them accessible via RPC and to write the client that connects to them.

The client

The client connects to the server with a Register message. It then enters a loop:

Command-line interface

The command-line interface is straightforward to set up:

func main() {
    log.SetFlags(log.Ltime)

    var listenAddress string
    var serverAddress string
    var username string

    switch len(os.Args) {
    case 2:
        listenAddress = net.JoinHostPort("", os.Args[1])
    case 3:
        serverAddress = os.Args[1]
        if strings.HasPrefix(serverAddress, ":") {
            serverAddress = "localhost" + serverAddress
        }
        username = strings.TrimSpace(os.Args[2])
        if username == "" {
            log.Fatal("empty user name")
        }
    default:
        log.Fatalf("Usage: %s <port>   OR   %s <server> <user>",
            os.Args[0], os.Args[0])
    }   

    if len(listenAddress) > 0 { 
        server(listenAddress)
    } else {
        client(serverAddress, username)
    }   
}

It calls the function server to kick off a server (and the whole process shuts down when server returns) or client to run a client instance.

RPC

The core of this assignment is to implement the RPC mechanism. Each server function needs the following:

The server stub always transmits an error message at the end of each response. This is just a string and an empty string means no error occurred. If the server function already has an error return value then nothing changes, but if it does not then it should be added in both the server and client stubs. For server functions with no return value, this is especially useful as it gives the client stub a way to know that the server function has finished running successfully. Without it, the client only knows that it transmitted the request parameters. It does not know for sure if they were received and processed okay.

With this infrastructure in place, calling a server function over RPC is very similar to calling it a regular function on the client with a few important differences:

  1. The client stub needs the server address as an extra parameter so it knows where to connect.

  2. The client stub always returns an error value, even if the ordinary server function cannot return an error. RPCs can have errors introduced by the network, a failed server, etc. Clients should always think about the additional failures that can be introduced in a distributed environment.

  3. The client and server cannot share memory, there is added latency in an RPC.

As an example, consider the CheckMessages function:

func CheckMessages(user string) []string { ... }

The client stub for this function should look like:

func CheckMessagesRPC(server string, user string) ([]string, error) { ... }

It connects to the server, writes the serialized request (see details below), waits for and deserializes the response, and returns. Note that the stub includes an error return value even though the original server function does not.

The server stub looks like:

func CheckMessagesServerStub(request []byte) []byte { ... }

The server dispatch function, which receives each incoming request, reads the request data from the network, parses the message type, and calls the server stub with the remaining request data (the message length and message type have already been parsed). It parses the arguments and then calls the original server function, serializes the results, and returns the serialized response.

Serializing parameters

We will use a straightforward scheme for serializing parameters and return values. You will need some primitives:

Before writing any parameters, the client stub should first write the message type using WriteUint16. The message types are defined as constants in the starter code given above.

An example exchange

Here is an example of the sequence of events for a single CheckMessages call. First the client side:

Note that every RPC has an error return value, regardless of whether or not the original server function can return an error. This means that every RPC call with have some return value (an empty error message at minimum).

On the server side:

You must implement a similar flow for each of the server RPC functions. To check if your implementation is correct, try connecting your client to the example server and the example client to your server. They should be interchangable since they implement the same API.