Skip to content

Latest commit

 

History

History
398 lines (294 loc) · 9.84 KB

guide.md

File metadata and controls

398 lines (294 loc) · 9.84 KB
description Lick IPC guide - building applications that communicate with external programs through Unix sockets.
layout
title description tableOfContents outline pagination
visible
true
visible
visible
true
visible
true
visible
true

Lick Guide

In this guide we'll write a pair of simple apps to demonstrate how Lick works. One will be a Gall agent called licker.hoon, and the other a Python script called licker.py.

The Gall agent will create a socket through Lick and the Python script will connect to it. When the Gall agent is poked with a message of %ping, it'll send it through the socket to the Python script. The Python script will print ping!, then send a %pong message back through the socket to the Gall agent, which will print pong! to the Dojo.

First, we'll look at these two files.

licker.hoon {#lickerhoon}

licker.hoon
/+  default-agent
|%
+$  card  card:agent:gall
--
^-  agent:gall
|_  =bowl:gall
+*  this  .
    def   ~(. (default-agent this %|) bowl)
::
++  on-init
  ^-  (quip card _this)
  :_  this
  [%pass /lick %arvo %l %spin /'licker.sock']~
::
++  on-poke
  |=  [=mark =vase]
  ^-  (quip card _this)
  ?>  ?=([%noun %ping] [mark !<(@tas vase)])
  :_  this
  [%pass /spit %arvo %l %spit /'licker.sock' %noun %ping]~
::
++  on-arvo
  |=  [=wire sign=sign-arvo]
  ^-  (quip card _this)
  ?.  ?=([%lick %soak *] sign)  (on-arvo:def +<)
  ?+    [mark noun]:sign        (on-arvo:def +<)
    [%connect ~]     ((slog 'socket connected' ~) `this)
    [%disconnect ~]  ((slog 'socket disconnected' ~) `this)
    [%error *]       ((slog leaf+"socket {(trip ;;(@t noun.sign))}" ~) `this)
    [%noun %pong]    ((slog 'pong!' ~) `this)
  ==
::
++  on-save   on-save:def
++  on-load   on-load:def
++  on-watch  on-watch:def
++  on-leave  on-leave:def
++  on-peek   on-peek:def
++  on-agent  on-agent:def
++  on-fail   on-fail:def
--

Our Gall agent is extremely simple and has no state. It only uses three agent arms: +on-init, +on-poke and +on-arvo.

+on-init {#on-init}

++  on-init
  ^-  (quip card _this)
  :_  this
  [%pass /lick %arvo %l %spin /'licker.sock']~

All +on-init does is pass Lick a %spin task to create a new licker.sock socket.

+on-poke {#on-poke}

++  on-poke
  |=  [=mark =vase]
  ^-  (quip card _this)
  ?>  ?=([%noun %ping] [mark !<(@tas vase)])
  :_  this
  [%pass /spit %arvo %l %spit /'licker.sock' %noun %ping]~

When +on-poke receives a poke with a $mark of %noun and data of %ping, it passes Lick a %spit task with the same data. Lick will send it on through to our licker.sock socket for our Python script. This lets us poke our agent from the Dojo like:

> :licker %ping

+on-arvo {#on-arvo}

++  on-arvo
  |=  [=wire sign=sign-arvo]
  ^-  (quip card _this)
  ?.  ?=([%lick %soak *] sign)  (on-arvo:def +<)
  ?+    [mark noun]:sign        (on-arvo:def +<)
    [%connect ~]     ((slog 'socket connected' ~) `this)
    [%disconnect ~]  ((slog 'socket disconnected' ~) `this)
    [%error *]       ((slog leaf+"socket {(trip ;;(@t noun.sign))}" ~) `this)
    [%noun %pong]    ((slog 'pong!' ~) `this)
  ==

+on-arvo expects a %soak gift from Lick. A %soak is primarily a message coming in from the socket, though connection status is also communicated in %soaks. The four cases we handle are:

  • %connect: An external process has connected to the socket.
  • %disconnect: An external process has disconnected from the socket.
  • %error: An error has occurred. The error message is a $cord in the $noun. The only time you'll get this is if you tried to %spit a message to the socket but there was nothing connected to it. In that case, the error message will be 'not connected'.
  • [%noun %pong]: This is the successful response we expect from the Python script.

In all cases we just +slog a message to the terminal.


licker.py {#lickerpy}

licker.py
from noun import *
import socket

def cue_data(data):
    x = cue(int.from_bytes(data[5:], 'little'))
    mark = intbytes(x.head).decode()
    noun = x.tail
    return (mark,noun)

def jam_result(mark, msg):
    mark = int.from_bytes(mark.encode(), 'little')
    noun = int.from_bytes(msg.encode(), 'little')
    return intbytes(jam(Cell(mark, noun)))

def make_output(jammed):
    length = len(jammed).to_bytes(4, 'little')
    version = (0).to_bytes(1, 'little')
    return version+length+jammed

sock_path = '/home/user/zod/.urb/dev/licker/licker.sock'
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect(sock_path)

while True:
    try:
        data = sock.recv(1024)
        mark, noun = cue_data(data)
    except TimeoutError:
        pass

    if (mark != 'noun'):
      pass

    msg = intbytes(noun).decode()
    if (msg != 'ping'):
      pass
  
    print('ping!')

    jammed = jam_result('noun', 'pong')
    output = make_output(jammed)

    sock.send(output)

Our Python script is also quite simple. We'll walk through it piece by piece.

from noun import *
import socket

First, we import the socket library and noun.py.

def cue_data(data):
    x = cue(int.from_bytes(data[5:], 'little'))
    mark = intbytes(x.head).decode()
    noun = x.tail
    return (mark,noun)

This function takes some data from the socket, decodes it, and returns a pair of the mark and noun. The data initially has the following format:

[1B: version][4B: size of jam in bytes][nB: jammed data]

The version is always 0 (though this may change in the future). The cue_data function just strips off the the version and size headers, but you may wish to verify these.

After that, cue_data converts the jam to an integer and passes it to the cue function in noun.py to decode. It converts the mark to a string, then returns it along with the raw noun.

def jam_result(mark, msg):
    mark = int.from_bytes(mark.encode(), 'little')
    noun = int.from_bytes(msg.encode(), 'little')
    return intbytes(jam(Cell(mark, noun)))

This function takes a mark string and msg string, converts them to integers, forms a cell and jams them with the jam function in noun.py. It's used to produce the jam when sending something back to the socket.

def make_output(jammed):
    length = len(jammed).to_bytes(4, 'little')
    version = (0).to_bytes(1, 'little')
    return version+length+jammed

Once jam_result has been run, make_output calculates the length of the jam, sets the version number, and puts it all together so it can be sent off to the socket.

sock_path = '/home/user/piers/zod/.urb/dev/licker/licker.sock'
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect(sock_path)

Here we specify the path to the socket and open the connection. Lick sockets live in:

<pier>/.urb/dev/<agent>/<socket name>

You'll need to change sock_path to your pier location.

while True:
    try:
        data = sock.recv(1024)
        mark, noun = cue_data(data)
    except TimeoutError:
        pass

    if (mark != 'noun'):
      pass

    msg = intbytes(noun).decode()
    if (msg != 'ping'):
      pass
  
    print('ping!')

    jammed = jam_result('noun', 'pong')
    output = make_output(jammed)

    sock.send(output)

This is the main loop of our script. It listens for a message from the socket, calls cue_data to decode it, checks it's an expected ping, prints it, produces a pong in response and sends it back to the socket.


Setup {#setup}

Create the folders for the project:

mkdir -p licker/{desk,client}
mkdir licker/desk/{app,lib,mar}

In the Dojo of a fakezod, mount the %base desk:

|mount %base

Copy across some dependencies (change the pier path if necessary):

cp -r zod/base/mar/{bill*,hoon*,kelvin*,mime*,noun*,txt*} licker/desk/mar/
cp -r zod/base/lib/{default-agent*,skeleton*} licker/desk/lib/

Add a desk.bill sys.kelvin files:

echo "[%zuse 408]" > licker/desk/sys.kelvin
echo "~[%licker]" > licker/desk/desk.bill

Open a licker.hoon app in an editor, paste in the licker.hoon code above, and save it:

nano licker/desk/app/licker.hoon

Open a licker.py file in an editor, paste in the licker.py code above, and save it:

nano licker/client/licker.py

Download the noun.py dependency from the urbit/tools repo:

wget -P licker/client https://raw.githubusercontent.com/urbit/build-on-urbit/tools/master/pkg/pynoun/noun.py

Install additional python dependencies bitstream, mmh3 and numpy:

{% hint style="info" %}

NOTE: At the time of writing, bitstream doesn't build against python>3.10. If you have 3.11 or newer, you may need to install a separate python3.10 (how your distro packages it may vary).

{% endhint %}

python -m ensurepip
pip install bitstream mmh3 numpy

Create and mount the %licker desk in the Dojo:

|new-desk %licker
|mount %licker

Delete the existing files and copy in the new ones:

rm -r zod/licker/*
cp -r licker/desk/* zod/licker/

In the Dojo, commit the files and install the desk:

|commit %licker
|install our %licker

Try it out {#try-it-out}

First, run the Python script:

python licker/client/licker.py

You should see the following in the Dojo:

socket connected

Now, try poking the %licker agent with %ping:

:licker %ping

In the terminal running the Python script, you should see:

ping!

And in the Dojo, you should see the response:

pong!

Now try closing the Python script. You should see the following in the Dojo:

socket disconnected

If you try :licker %ping again, you'll see this error message:

socket not connected