Introduction to Janet RPC

Janet is a modern Lisp with a rich standard library in a tiny package. Janet is quickly becoming my favorite Lisp for tasks I might have accomplished with bash/sh or python in the past thanks to its ability to produce small statically linked binaries. In this article we’ll explore remote procedure calls (RPC) with the spork package. Since RPC involves making calls over a network all code examples are divided by the client and server implementation in client.janet and server.janet. Follow along by running the examples on your own machine. You can view the example in once place as a gist.

Setup

The spork/prc package is an external library. Create a file called project.janet and add the following.

(declare-project :name "rpc"
                 :description "RPC test."
                 :dependencies ["https://github.com/janet-lang/spork.git"])

Afterwards, run the command jpm deps to download and install the spork package.

The Basics

Lets start by creating a basic RPC server and client to demonstrate how it works.

server.janet

rpc/server accepts a table of functions that a client can call. In this example we have function called :print that prints whatever the value of x is. Note that for ever RPC function we must also provide an argument for self, which is a reference to the object the :print method is implemented on. Yes, we are doing OOP in a Lisp.

(import spork/rpc)

(def functions
  @{:print (fn [self x] (print x))})

(rpc/server functions "127.0.0.1" "9001")
client.janet

To create a client we need to tell it where the server is listening. We can also provide a name for our client. Since the client is an object with RPC methods that can be called the methods can be called like methods on any object created in Janet. For example if we bind the client to c we can call its :print method with (:print c "Hello world!). This will print “Hello world!” on the server.

(import spork/rpc)

(def c (rpc/client "127.0.0.1" "9001" "joe"))

# Will print on server
(:print c "Hello world!")

Returning Values

RPC is not much use if you can’t get data back. Before we explore how to go about doing that lets take a look at how we can make writing our RPC interface a little more ergonomic. Personally, I hate writing complex anonymous functions in the scope of a table like the previous examples. Luckily, we don’t have to. An alternative implementation where :print is defined outside of the functions table might look something like this.

server.janet
(defn print [self x]
  (print x))

(def functions
  @{:print print})

For the sake of brevity, I’ll assume you understand that any function defined in server.janet is added to the functions table like the example above.

So far we haven’t defined any functions capable of returning a value. :print calls print which writes to stdout and returns nil. Okay, I suppose you could say we haven’t written anything that doesn’t just return nil because nil is a value, just not a very interesting one.

server.janet

Lets add a function called add.

(defn add [self xs]
  (sum xs)

Remember to add add to functions table under the :add key.

client.janet

We can call :add with our client to get the sum of the numbers we provide.

(:add c 1 3 5 7 9) # -> 25

This function will return the sum of the numbers, 25 in this case. If we want to, we can print the result.

(print (:add c 1 3 5 7 9))

Fibers over RPC

A fiber can be stopped and resumed later allowing a function to return multiple times. A fiber returns a value via yield. To make a fiber yield a value one must resume it.

server.janet

Lets make a new RPC function called fiber that returns a new fiber. In that fiber is a function that yields values in a loop. Each time we resume it we get the next value.

(defn fiber [self]
  (fiber/new (fn []
               (for i 0 5
                 (yield i)))))

Remember to add this to the functions table under :fiber.

client.janet

Here our client calls the :fiber method and binds it to fiber. Afterwards it resumes the fiber in a loop printing each value as it resumes.

(let [fiber (:fiber c)]
  (for i 0 5
    (print (resume fiber))))

What if we need to send values to a remote fiber while also reading from it? For reasons I can’t begin to comprehend I spent my Saturday indulging this thought exercise of enabling bi-directional (bidi) data flow over RPC fibers in Janet. This is what I came up with.

server.janet

This RPC function accepts a remote fiber from the client that we’ll call rfiber. It resumes the remote fiber in a loop and then increments and yields the value back to the client.

(defn bidi [self rfiber]
  (fiber/new (fn []
               (while true
                 (yield (inc (resume rfiber)))))))

Observant readers might point out that this loop is infinite, which is true, but remember that the fiber is suspended after yielding so it isn’t really an infinite loop unless it is also resumed infinitely.

client.janet

Here we define the fiber on the client which will act as the remote fiber that passes values to the server. To avoid the infinite loop issue we’ll resume the fiber in a try. On each iteration we’ll push the values to an array. When the fiber is dead it will raise an error. At that time we can print the values and break from the loop.

(def f (fiber/new (fn []
                    (for i 0 5
                      (yield i)))))

(let [bidi (:bidi c f)
      arr @[]]
  (while true
    (try # we can't see if the fiber f is dead because the rpc server is consuming it not the client.
      (let [v (resume bidi)]
        (if v # v is nil if fiber is dead and we don't want that.
          (array/push arr v)))
      ([err] (do
               (pp arr)
               (break))))))

Sending a File

Up until now we focused on things that are debatably interesting but dubiously useful. Wouldn’t it be nice to be useful? Transferring a file is certainly useful.

server.janet

Something not so obvious is that not all of Janet’s core library can be serialized for use over RPC. The file API for example cannot be serialized for RPC. Intuitively, I thought wrapping the file API in a fiber for the client to consume was a convenient way to transfer a file over RPC but it doesn’t work.

If you just wanted to send the whole file at once you could do that without involving fibers. However, this will not work in situations where the file you want to transfer is particularly large or perhaps continuously appended like a log. To enable file streaming over RPC I expose an interface similar to the file API where the client first “opens” the file which stores the file handle in a table on the server called handles. You could think of this as a table of virtual file handles that can be used to look up the actual file handle when the client wants to read from the file.

Each time the client reads the file it also supplies the virtual handle so the server knows which file to read from. This allows us to transfer a file line by line or bit by bit instead of all at once. The virtual handle is created by the function make-handle which generates a random string that can be used as the key in the handles table to store the reference to the actual file handle on the server.

(def handles @{})

(defn make-handle []
  (string/join (map | (string/format "%02x" $) (os/cryptorand 12))))

(defn open [self file & mode]
  (let [handle (make-handle)]
    (put handles handle (file/open file))
    handle))

(defn read [self handle what]
  (file/read (handles handle) what))
client.janet

Lets start with a simple example where we simply open and read the entire file.

(let [file (:file/open c "./foo.txt")]
  (pp (:file/read c file :all)))

Here we “open” the file on the remote host and receive the virtual file handle back. The virtual handle is bound to file and then we call the :file/read method, supplying the virtual handle to the server so it knows which file to read. The interface is very similar to the actual Janet file API.

Here is a more complicated example where we stream a remote file called foo.txt and write it to local file called bar.txt line by line.

(let [file (:file/open c "./foo.txt")
      to (file/open "./bar.txt" :a)]
  (while true
    (let [line (:file/read c file :line)]
      (if line
        (file/write to line)
        (break))))
  (file/close to)
  (print (file/read (file/open "./bar.txt") :all)))

Now that we are done, we should close the client connection by calling (:close c) on our client.

Conclusion

Although spork/rpc is simple it opens the door to great deal of possibilities for creating distributed systems with Janet. Additionally the interfaces created can be nearly identical to a given API as I have demonstrated with the file transfer example. The complete examples of the client and server are available as gist.