A Weird Imagination

Client/server over named pipes

Posted in

The problem#

Firefox Marionette only allows a single client to connect a time, so I'd like to have a program in charge of holding that connection that can communicate with the other parts of the system that know what I want Firefox to actually do. While a common way of handling this is to run an HTTP server, that seems pretty heavyweight and would allow access to any user on the machine.

This can be generalized to any case where we want to be able to control one program from another on the same computer. Some reasons why we might not be able to simply act from the first program is if the latter has different access permissions or is holding onto some state like, as mentioned, an open socket.

The solution#

The two programs can communicate over a named pipe, also known as a FIFO. The mkfifo command creates one on the filesystem:

$ mkfifo a_pipe
$ chown server_uid:clients_gid a_pipe
$ chmod 620 a_pipe

Then you can use tail -f a_pipe to watch the pipe and echo something > pipe to write to the pipe. To add a bit more structure, here's a very simple server and client in Python where the client sends one command per line as JSON and the server processes the commands one at a time:

# server.py
import fileinput
import json

def process_cmd(cmd, *args):
    print(f"In process_cmd({cmd})...")

for line in fileinput.input():
    try:
        process_cmd(*json.loads(line))
    except Exception as ex:
        print("Command failed:")
        print(ex, flush=True)
# client.py
import json
import sys

print(json.dumps(sys.argv[1:]))
# Run the server.
$ tail -f a_pipe | python server.py
# Send some commands using the client.
$ python client.py cmd foo bar > a_pipe
$ python client.py another_cmd baz > a_pipe

Then the server will print out

In process_cmd(cmd)...
In process_cmd(another_cmd)...

The details#

Creating the named pipe#

Named pipes look a lot like files, so you can put them on the filesystem anywhere you would put a file, and assign them permissions like any other file. This allows you to effectively use Unix file system permissions to control access to your server program.

Server lifetime management#

Note that you should only listen to a named pipe from a single program at a time. There's nothing stopping you from running tail -f a_pipe multiple times at once, but each message will go to only one of them. If you want to make sure no other server process is reading the pipe, the simplest way is to just delete it and recreate it. You could also use lsof to check what processes have it open. Consider using the --pid option of tail to make sure it doesn't stay open longer than it should.

Connecting within Python#

The example code above does all of the I/O in the shell, so the server could actually be written in any language. But this also means that attaching to the pipe has to happen at the same time the server process is started. Instead, it's pretty easy to do the equivalent of tail -f in Python code:

path = './a_pipe'
while True:
    with open(path, 'r') as f:
        for line in f:
            process_cmd(json.loads(line))

Similarly, the client connection could happen inside a larger Python program:

with open(path, 'w') as f:
    f.write(json.dumps(sys.argv[1:]) + '\n')

Replying to the client#

This communication mechanism is unidirectional, so if the client needs to get any information from the server (e.g., a confirmation that the command was received and/or completed), then you need another communication channel.

One possibility is a second named pipe going the other direction, but since sending messages on a pipe blocks if there is no listener, that would let a buggy client deadlock the server, so might not be the best idea. Another possibility would be writing the response to a file, either in a known location or specified in the command arguments. Alternatively, if bidirectional communication is needed, then a different IPC mechanism may be more appropriate.

A Marionette command handler#

Below is a basic version of a process_cmd() function for communicating with Firefox Marionette.

As discussed above, the listtabs command communicates back to the client by writing a file ./tabs.json which contains a JSON object with the handles, window positions, and URLs of the current tabs.

Also, it uses remove-decorations from my post on borderless Firefox to remove the window borders from all Firefox windows when resizing. Note that this means that this Python code has to be run by the same user that owns the graphical session Firefox is running under; otherwise it's just connecting to a socket which any user could do.

import subprocess

from marionette_driver.marionette import Marionette

client = Marionette('localhost', port=2828)
info = client.start_session()
pid = info['moz:processID']
print(f"Connected to Firefox Marionette (pid={pid}).", flush=True)


def process_cmd(cmd, *args):
    if cmd == 'navigate':
        client.navigate(args[0])
    elif cmd == 'newtab':
        client.open('tab', True, False)
    elif cmd == 'newwindow':
        client.open('window', True, False)
    elif cmd == 'listtabs':
        tabs = []
        original_window = client.current_window_handle
        for handle in client.window_handles:
            client.switch_to_window(handle)
            tabs.append({
                "handle": handle,
                "rect": client.window_rect,
                "url": client.get_url()})
        client.switch_to_window(original_window)
        with open('tabs.json', 'w') as f:
            f.write(json.dumps(tabs))
    elif cmd == 'switchtab':
        client.switch_to_window(args[0])
    elif cmd == 'closetab':
        client.close()
    elif cmd == 'fullscreen':
        client.fullscreen()
    elif cmd == 'resize':
        client.set_window_rect(*map(int, args))
        subprocess.run(f'xdotool search --pid {pid} --onlyvisible '
                       + '| xargs -n1 remove-decorations',
                       shell=True)
    else:
        raise Exception(f"Unknown command: {cmd}")

Comments

Have something to add? Post a comment by sending an email to comments@aweirdimagination.net. You may use Markdown for formatting.

There are no comments yet.